<?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: Jonás Javier Encarnación</title>
    <description>The latest articles on DEV Community by Jonás Javier Encarnación (@jonasjavier).</description>
    <link>https://dev.to/jonasjavier</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%2F4156063%2Fe36d42bc-de8b-4b50-9629-879dfb46a0b3.jpg</url>
      <title>DEV Community: Jonás Javier Encarnación</title>
      <link>https://dev.to/jonasjavier</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonasjavier"/>
    <language>en</language>
    <item>
      <title>How to hire a freelance web developer in the Dominican Republic</title>
      <dc:creator>Jonás Javier Encarnación</dc:creator>
      <pubDate>Fri, 02 Oct 2026 21:45:36 +0000</pubDate>
      <link>https://dev.to/jonasjavier/how-to-hire-a-freelance-web-developer-in-the-dominican-republic-28h9</link>
      <guid>https://dev.to/jonasjavier/how-to-hire-a-freelance-web-developer-in-the-dominican-republic-28h9</guid>
      <description>&lt;p&gt;Every so often I hear from someone who's already had a bad experience: they&lt;br&gt;
paid for a website or a system, got something half finished, and now they have&lt;br&gt;
neither the code nor access to their own domain. It's almost never a talent&lt;br&gt;
problem. It's a hiring problem: no written scope, no progress to look at, and&lt;br&gt;
no clarity about who owns what.&lt;/p&gt;

&lt;p&gt;I'm a freelance full-stack developer in Santo Domingo, and I work with clients&lt;br&gt;
here and abroad. This guide is what I'd ask for if I were on the other side of&lt;br&gt;
the table. It works for hiring me, and it works just as well for hiring anyone&lt;br&gt;
else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you search: what problem are you solving?
&lt;/h2&gt;

&lt;p&gt;"I need a website" is the most common starting point and the least useful one.&lt;br&gt;
Before you talk to anyone, try to answer three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who will use it, and for what?&lt;/strong&gt; A site that presents your business, a
store where people pay, an internal app your team works in every day, and an
app on your customers' phones are four different projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does success look like?&lt;/strong&gt; More bookings, fewer calls asking the same
question, an inventory that finally adds up. If you can say it in one
sentence, the developer can design for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What do you already have?&lt;/strong&gt; A logo, photos, a catalog in Excel, an
Instagram account that works. All of that saves time and money.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You don't need to know technology. You need to know what you want to happen&lt;br&gt;
once the project is live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look at full case studies, not screenshots
&lt;/h2&gt;

&lt;p&gt;A portfolio of pretty screenshots tells you little. What actually informs you&lt;br&gt;
is a case told in full: what the problem was, what was decided and why, what&lt;br&gt;
it was built with, and what happened after. If a developer can explain their&lt;br&gt;
decisions, they'll be able to explain yours.&lt;/p&gt;

&lt;p&gt;In my case, projects are published that way. &lt;a href="https://jonasjavier.dev/en/projects/omsta" rel="noopener noreferrer"&gt;OMSTA&lt;/a&gt; is&lt;br&gt;
the management system of a real travel agency, with its mobile app, and&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/projects/delicate-4-0" rel="noopener noreferrer"&gt;Delicaté&lt;/a&gt; is an online store running in production&lt;br&gt;
on its own domain. In each one you can read the scope, the design decisions&lt;br&gt;
and the engineering, and open the demo where there is one.&lt;/p&gt;

&lt;p&gt;Two questions worth asking about any case: &lt;strong&gt;is it in use today?&lt;/strong&gt; and&lt;br&gt;
&lt;strong&gt;which part did you build?&lt;/strong&gt; Both have honest answers and evasive answers,&lt;br&gt;
and you can tell them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask for a written proposal
&lt;/h2&gt;

&lt;p&gt;"How much does it cost?" is the question everyone asks first, and the honest&lt;br&gt;
answer is always the same: it depends on the scope. What you can demand is that&lt;br&gt;
the scope be written down before you pay anything. A serious proposal has, at a&lt;br&gt;
minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What will be built&lt;/strong&gt;, in language you understand, screen by screen or
feature by feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's not included.&lt;/strong&gt; This section prevents most arguments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A timeline&lt;/strong&gt; in dates or weeks, not "soon".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget and payment terms&lt;/strong&gt;, with what's delivered at each payment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's needed from you&lt;/strong&gt; and by when: copy, photos, access, decisions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I send that proposal after the first conversation and before writing a line of&lt;br&gt;
code. If someone gives you a fixed price in their first message, having barely&lt;br&gt;
asked you anything, they're guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Progress you can open on your phone
&lt;/h2&gt;

&lt;p&gt;The simplest way to avoid surprises is to see the project while it's being&lt;br&gt;
built. Not screenshots: &lt;strong&gt;a real link&lt;/strong&gt; you can open on your computer or your&lt;br&gt;
phone, tap through, and show to anyone you like.&lt;/p&gt;

&lt;p&gt;In my workflow, every milestone goes up on a link like that. It does two&lt;br&gt;
things: you see that the thing exists and is moving, and we both catch what&lt;br&gt;
needs changing early, while changing it is cheap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7h4vp98cjr9b1wcimvx4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7h4vp98cjr9b1wcimvx4.webp" alt="The “How I work” section of the services page, with its four steps: conversation, proposal, build and launch." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The four steps I follow on every project. The third one, build, is the one that brings the most peace of mind: progress on a real link, not on promises.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask who owns the domain, the code and the accounts
&lt;/h2&gt;

&lt;p&gt;This is where most people lose out. Before starting, get it in writing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The domain is in your name.&lt;/strong&gt; You hold the account it was registered
with, not the developer. If they register it for you, they transfer it or
hand you the access when the work is done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting and services&lt;/strong&gt; (email, payment gateway, database) live in your
accounts, or in accounts where you have owner access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The code is yours&lt;/strong&gt; once it's paid for, and you receive a copy or access
to the repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's a list of credentials&lt;/strong&gt; delivered at the end, with everything
someone else would need to pick up the work tomorrow if it came to that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A serious developer isn't bothered by these questions. They're normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens after launch
&lt;/h2&gt;

&lt;p&gt;A website or an app isn't finished the day it goes live. There will be&lt;br&gt;
adjustments, a domain renewal every year, and one day you'll want to add&lt;br&gt;
something. Ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who handles publishing it and the domain?&lt;/li&gt;
&lt;li&gt;What happens if a bug shows up in the first weeks?&lt;/li&gt;
&lt;li&gt;How are small changes billed afterwards?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my case, I take care of the release and the domain, and I stay around after&lt;br&gt;
delivery. What matters is that you know beforehand, not that you find out when&lt;br&gt;
something breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remote or in person, in English or Spanish
&lt;/h2&gt;

&lt;p&gt;Working with someone in the Dominican Republic has an obvious upside if your&lt;br&gt;
business is here: same time zone, same language, and meeting in person when it&lt;br&gt;
helps. But hiring a freelancer no longer requires them to be in your city. I&lt;br&gt;
work remotely with clients in any country, in English or Spanish, and in Santo&lt;br&gt;
Domingo we can meet face to face.&lt;/p&gt;

&lt;p&gt;What remote work does require is clear communication: an agreed channel,&lt;br&gt;
answers within a reasonable time, and decisions in writing. Ask what that&lt;br&gt;
communication will look like before you start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warning signs
&lt;/h2&gt;

&lt;p&gt;A few things that, if you see them, are worth stopping for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nothing is in writing, or the scope is one sentence.&lt;/li&gt;
&lt;li&gt;They can't show you a project in use, or can't explain what they did on it.&lt;/li&gt;
&lt;li&gt;Everything will be "ready in a week", whatever it is.&lt;/li&gt;
&lt;li&gt;They don't ask you questions about your business.&lt;/li&gt;
&lt;li&gt;The domain or hosting is "easier if I put it in my name".&lt;/li&gt;
&lt;li&gt;They ask for full payment up front.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these proves bad faith on its own. Together, they almost always do.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I work
&lt;/h2&gt;

&lt;p&gt;So you're not left with theory, this is what a project with me looks like from&lt;br&gt;
start to finish:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Conversation.&lt;/strong&gt; I learn the problem, who will use it and what success
looks like.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proposal.&lt;/strong&gt; Scope, timeline and budget in writing, before we start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build.&lt;/strong&gt; I design the interface before coding it, and every milestone
goes up on a real link you can open and try.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launch.&lt;/strong&gt; Release, domain and support after delivery.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I build custom web applications with Django and React, online stores and&lt;br&gt;
business sites, mobile apps with React Native and, when a project calls for it,&lt;br&gt;
3D experiences like the ones on this site. All with published case studies you&lt;br&gt;
can open before you write to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write to me
&lt;/h2&gt;

&lt;p&gt;If you have a project in mind, tell me about it on the&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;services page&lt;/a&gt; or straight through&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/contact" rel="noopener noreferrer"&gt;Contact&lt;/a&gt;. I read every message and reply personally. And if all&lt;br&gt;
you wanted was this guide to hire someone else, use it: that's what it's for.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasjavier.dev/en/blog/how-to-hire-a-freelance-web-developer-dominican-republic" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;, next to the &lt;a href="https://jonasjavier.dev/en/blog" rel="noopener noreferrer"&gt;blog&lt;/a&gt;. I'm Jonás Javier Encarnación, a full-stack developer and UX/UI designer in Santo Domingo, Dominican Republic: &lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>career</category>
      <category>webdev</category>
      <category>business</category>
    </item>
    <item>
      <title>The physics of Interstellar's black hole, seen in a simulator</title>
      <dc:creator>Jonás Javier Encarnación</dc:creator>
      <pubDate>Fri, 02 Oct 2026 21:36:13 +0000</pubDate>
      <link>https://dev.to/jonasjavier/the-physics-of-interstellars-black-hole-seen-in-a-simulator-3mb8</link>
      <guid>https://dev.to/jonasjavier/the-physics-of-interstellars-black-hole-seen-in-a-simulator-3mb8</guid>
      <description>&lt;p&gt;The first time I watched &lt;em&gt;Interstellar&lt;/em&gt; I assumed Gargantua was a very good&lt;br&gt;
visual effect. Years later, building&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/experiments/observatory/gargantua" rel="noopener noreferrer"&gt;my own simulator&lt;/a&gt; for this site, I&lt;br&gt;
understood it's closer to the opposite: very little in that image is invented.&lt;br&gt;
The shadow, the disk wrapping over and under it, the thin ring of light hugging&lt;br&gt;
the edge: all of it comes out of one equation, the one that describes how light&lt;br&gt;
bends near an enormous mass.&lt;/p&gt;

&lt;p&gt;This post explains that physics without hard maths, using the simulator as a&lt;br&gt;
whiteboard. If you want to know how it's programmed (the shader, the steps,&lt;br&gt;
the performance), that's in&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/blog/how-i-built-a-black-hole-in-webgl" rel="noopener noreferrer"&gt;How I built a black hole in WebGL&lt;/a&gt;.&lt;br&gt;
Here the question is different: &lt;strong&gt;why does a black hole look like this?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Gargantua is
&lt;/h2&gt;

&lt;p&gt;Gargantua is the black hole in the film &lt;em&gt;Interstellar&lt;/em&gt; (2014). The physicist&lt;br&gt;
Kip Thorne, who advised the production and later won the Nobel Prize for the&lt;br&gt;
detection of gravitational waves, asked for it to be computed for real: the&lt;br&gt;
effects studio Double Negative wrote a ray tracer that followed light around a&lt;br&gt;
spinning black hole, and the result was published in 2015 as a scientific paper&lt;br&gt;
in the journal &lt;em&gt;Classical and Quantum Gravity&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In the story, Gargantua is a supermassive black hole, about a hundred million&lt;br&gt;
times the mass of the Sun, and it spins very fast. Neither detail is set&lt;br&gt;
dressing: the mass decides the size of everything you see, and the spin is&lt;br&gt;
what makes Miller's planet possible, where one hour equals seven years on&lt;br&gt;
Earth. A black hole that didn't spin wouldn't allow an orbit that close to the&lt;br&gt;
horizon, and without that closeness there'd be no time dilation that extreme.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shadow: why the centre is black
&lt;/h2&gt;

&lt;p&gt;A black hole isn't an object you see: it's a region light doesn't leave. Its&lt;br&gt;
boundary is the &lt;strong&gt;event horizon&lt;/strong&gt;, and for a non-spinning one its radius is the&lt;br&gt;
Schwarzschild radius, &lt;code&gt;rs&lt;/code&gt;. For a mass like the Sun's that's about three&lt;br&gt;
kilometres; for Gargantua, hundreds of millions.&lt;/p&gt;

&lt;p&gt;The curious part is that the black patch you see on screen is quite a bit&lt;br&gt;
bigger than the horizon. At 1.5 times &lt;code&gt;rs&lt;/code&gt; sits the &lt;strong&gt;photon sphere&lt;/strong&gt;: the&lt;br&gt;
distance at which light can circle the hole completely. Any ray that passes&lt;br&gt;
inside that sphere ends up falling in. Seen from far away, that produces a&lt;br&gt;
shadow whose apparent radius is &lt;code&gt;√27/2 · rs&lt;/code&gt;, about 2.6 times the horizon. In&lt;br&gt;
the simulator you can measure it: the shadow takes up 2.6 times what the&lt;br&gt;
horizon would, and nobody drew it at that size. It falls out of following the&lt;br&gt;
light.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq1uyg8v21ve8crxtd9m1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq1uyg8v21ve8crxtd9m1.webp" alt="The simulator's Shadow view: the edge of the shadow up close, pure black against the bright disk." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Shadow view. The black in the centre isn't a texture: it's the set of rays that, traced backwards from the camera, end up inside the horizon.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The accretion disk, above and below
&lt;/h2&gt;

&lt;p&gt;What glows around Gargantua is the &lt;strong&gt;accretion disk&lt;/strong&gt;: gas spiralling into the&lt;br&gt;
hole, heated by friction until it emits light. It's a flat disk, like Saturn's&lt;br&gt;
rings. So why does it also show above and below the shadow, like a halo?&lt;/p&gt;

&lt;p&gt;Because light leaving the back of the disk doesn't travel in a straight line.&lt;br&gt;
Gravity bends it over the top of the hole and under the bottom, and it reaches&lt;br&gt;
the camera from above and from below. The upper arc is the far side of the disk&lt;br&gt;
seen over the shadow; the lower arc is the same far side seen under it. That's&lt;br&gt;
the phenomenon called &lt;strong&gt;gravitational lensing&lt;/strong&gt;, and it's why Gargantua doesn't&lt;br&gt;
look like a ringed planet but like something nobody had seen before the film.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkittru78itv6b4gi2evw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkittru78itv6b4gi2evw.webp" alt="The simulator's Lens view: the large shadow in the centre, the disk crossing it and its doubled image above and below." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Lens view. A flat disk also appears above and below the shadow: gravity brings the hidden side all the way to the camera.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In the simulator, the &lt;strong&gt;Lens&lt;/strong&gt; control turns that bending off. Without it the&lt;br&gt;
disk looks like what it geometrically is, a flat ring, and the image loses&lt;br&gt;
exactly what makes it recognisable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The photon ring and the secondary images
&lt;/h2&gt;

&lt;p&gt;Hugging the edge of the shadow there's a very thin, very bright ring. It's the&lt;br&gt;
&lt;strong&gt;photon ring&lt;/strong&gt;: light that went half a turn, or a full turn, around the hole&lt;br&gt;
before escaping towards the camera. Each extra turn produces a thinner image&lt;br&gt;
closer to the edge, and in theory there are infinitely many, each finer than&lt;br&gt;
the last.&lt;/p&gt;

&lt;p&gt;In the simulator, the &lt;strong&gt;Secondary images&lt;/strong&gt; control decides how many of those&lt;br&gt;
turns are followed. With it off, the edge of the shadow gets poorer; with it on,&lt;br&gt;
the blade of light appears and, under the disk, a second, smaller image of the&lt;br&gt;
hidden side. In the first version of my Gargantua that ring was drawn as a&lt;br&gt;
one-pixel circle; it looked like a painted circle and I removed it. Now it's&lt;br&gt;
what light does when it stacks up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doppler: the approaching side shines brighter
&lt;/h2&gt;

&lt;p&gt;Here's the part the film decided not to show. The gas in the disk spins at a&lt;br&gt;
sizeable fraction of the speed of light. The side coming towards you emits&lt;br&gt;
brighter, bluer light; the side moving away, dimmer and redder. That's the&lt;br&gt;
&lt;strong&gt;relativistic Doppler effect&lt;/strong&gt;, the same principle that changes the pitch of a&lt;br&gt;
siren as it passes, applied to light. On top of it comes &lt;strong&gt;gravitational&lt;br&gt;
redshift&lt;/strong&gt;: light leaving from near the hole loses energy on its way up.&lt;/p&gt;

&lt;p&gt;A real black hole would look &lt;strong&gt;lopsided&lt;/strong&gt;: one side of the disk clearly&lt;br&gt;
brighter than the other. Thorne and the effects team computed it, and&lt;br&gt;
Christopher Nolan chose to drop it from the film because audiences wouldn't&lt;br&gt;
have understood why the disk looked skewed. It's documented in the 2015 paper:&lt;br&gt;
Gargantua in the cinema is a symmetric, deliberately simplified version.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflnjxo58x12nrf8swe0h.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflnjxo58x12nrf8swe0h.webp" alt="Gargantua with the Doppler effect: the left side of the disk, moving towards the viewer, is much brighter than the right." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;With Doppler. The approaching side shines a little over twice as bright as the receding one, and shifts towards cream while the other stays copper.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuvedg1pr4rt4owdt40bb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuvedg1pr4rt4owdt40bb.webp" alt="The same frame without the Doppler effect: the disk looks almost symmetric." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Without Doppler. This, more or less, is how the cinema preferred to show it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In the simulator, Doppler is on by default, because I wanted to see the version&lt;br&gt;
physics asks for. With the &lt;strong&gt;Doppler&lt;/strong&gt; control you can turn it off and see the&lt;br&gt;
"film" version. It's the control that changes the image the most of the three.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the simulator does differently from the film
&lt;/h2&gt;

&lt;p&gt;I want to be precise about what this Gargantua is and isn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't spin.&lt;/strong&gt; The film's is a Kerr black hole, a rotating one. Mine
uses the Schwarzschild geometry, that of a non-rotating black hole, because
tracing the rotation pixel by pixel in real time in a browser was too
expensive. What does spin is the gas in the disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The disk is further out.&lt;/strong&gt; Around a non-spinning black hole, gas can't
orbit stably inside three times the horizon. Around one spinning very fast,
like Gargantua, that innermost orbit moves much closer in, which is why in the
film the disk reaches almost to the shadow. In the simulator the disk starts a
little inside that orbit, at 1.58 times the horizon, and extends out to 17.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doppler is on.&lt;/strong&gt; The film removed it; I left it in, and it can be turned
off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The disk is a drawing, not a gas simulation.&lt;/strong&gt; Its texture is noise that
rotates at different speeds with distance, as the gas would, but nobody is
solving fluid equations here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is identical is the part that matters: light follows, ray by ray, the&lt;br&gt;
geodesic equation of a black hole. The shadow, the arcs, the photon ring and the&lt;br&gt;
secondary image aren't painted anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use the simulator
&lt;/h2&gt;

&lt;p&gt;Open &lt;a href="https://jonasjavier.dev/en/experiments/observatory/gargantua" rel="noopener noreferrer"&gt;Gargantua in the Observatory&lt;/a&gt;&lt;br&gt;
and switch to &lt;strong&gt;Study&lt;/strong&gt; mode. There are four views:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cinematic&lt;/strong&gt;: the film's framing, with the disk edge-on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lens&lt;/strong&gt;: closer and higher, to see both arcs and the secondary image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk&lt;/strong&gt;: the disk almost face-on, where the gas's differential rotation
shows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shadow&lt;/strong&gt;: the edge of the shadow up close, where the photon ring shows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And three controls that switch real physics off: &lt;strong&gt;Doppler&lt;/strong&gt;, &lt;strong&gt;Secondary&lt;br&gt;
images&lt;/strong&gt; and &lt;strong&gt;Lens&lt;/strong&gt;. My advice is to turn them off one at a time and watch&lt;br&gt;
which part of the image disappears: that's exactly what each phenomenon&lt;br&gt;
contributes.&lt;/p&gt;

&lt;p&gt;The simulator runs in any browser with WebGL2. If the device can't manage it,&lt;br&gt;
or if you'd rather not spin up the graphics card, the site shows a flat version&lt;br&gt;
with the same content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions I get asked
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Has anyone seen a real black hole?&lt;/strong&gt; Yes. In 2019 the Event Horizon&lt;br&gt;
Telescope published the image of the black hole at the centre of the galaxy&lt;br&gt;
M87, and in 2022 the one at the centre of our own galaxy, Sagittarius A*. They&lt;br&gt;
are blurry orange rings, very different from Gargantua, but they show the same&lt;br&gt;
thing: a central shadow and light bent around it. And yes, the brighter side of&lt;br&gt;
M87's ring is the one moving towards us.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Gargantua exist?&lt;/strong&gt; No. It's a fictional object with a mass and a spin&lt;br&gt;
chosen so the story works. What does exist is the physics it was computed with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is one hour seven years on Miller's planet?&lt;/strong&gt; Gravitational time&lt;br&gt;
dilation: near an enormous mass, time runs slower. For the difference to be&lt;br&gt;
that large the planet has to orbit very close to the horizon, and that's only&lt;br&gt;
possible if the black hole spins almost at the limit. It's one of the places&lt;br&gt;
where the film stretched the physics as far as physics allows, but not beyond.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is the film's disk so faint?&lt;/strong&gt; A real disk around a black hole swallowing&lt;br&gt;
a lot of gas would blaze in X-rays and fry any nearby planet. The film's is,&lt;br&gt;
on purpose, a cold, anaemic disk, so the crew can get close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Open the &lt;a href="https://jonasjavier.dev/en/experiments/observatory/gargantua" rel="noopener noreferrer"&gt;simulator&lt;/a&gt; and, before&lt;br&gt;
touching anything, try to find the shadow, the two arcs and the photon ring.&lt;br&gt;
Then turn &lt;strong&gt;Lens&lt;/strong&gt; off and watch the halo vanish into a flat ring. That&lt;br&gt;
difference is general relativity.&lt;/p&gt;

&lt;p&gt;If what interests you is how it's built inside,&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/blog/how-i-built-a-black-hole-in-webgl" rel="noopener noreferrer"&gt;here's the shader&lt;/a&gt;. And if you'd&lt;br&gt;
like a 3D experience like this for your product or brand, here's&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasjavier.dev/en/blog/interstellar-black-hole-gargantua-explained" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;, next to the &lt;a href="https://jonasjavier.dev/en/experiments/observatory/gargantua" rel="noopener noreferrer"&gt;Observatory&lt;/a&gt;. I'm Jonás Javier Encarnación, a full-stack developer and UX/UI designer in Santo Domingo, Dominican Republic: &lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>science</category>
      <category>physics</category>
      <category>space</category>
      <category>webgl</category>
    </item>
    <item>
      <title>A bilingual Next.js site without middleware</title>
      <dc:creator>Jonás Javier Encarnación</dc:creator>
      <pubDate>Fri, 02 Oct 2026 21:34:56 +0000</pubDate>
      <link>https://dev.to/jonasjavier/a-bilingual-nextjs-site-without-middleware-5fp8</link>
      <guid>https://dev.to/jonasjavier/a-bilingual-nextjs-site-without-middleware-5fp8</guid>
      <description>&lt;p&gt;This site is fully in English and Spanish. English is the default and Spanish&lt;br&gt;
is one click away, in the header. Every page has its own address in each&lt;br&gt;
language — &lt;code&gt;/en/about&lt;/code&gt; and &lt;code&gt;/es/sobre-mi&lt;/code&gt;, &lt;code&gt;/en/contact&lt;/code&gt; and &lt;code&gt;/es/contacto&lt;/code&gt; —&lt;br&gt;
and Google knows they're the same page in two languages, not two pages&lt;br&gt;
competing with each other.&lt;/p&gt;

&lt;p&gt;All of it is built on the Next.js 16 App Router, with no internationalisation&lt;br&gt;
library and no middleware. This post walks through how, with the real code,&lt;br&gt;
and above all why each decision is the way it is.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three decisions before writing any code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Each language has its own URL.&lt;/strong&gt; No serving English or Spanish at the same&lt;br&gt;
address depending on who's asking. A page that changes with the&lt;br&gt;
&lt;code&gt;Accept-Language&lt;/code&gt; header can't be cached reliably, can't be linked knowing what&lt;br&gt;
the other person will see and — most importantly for search — is always&lt;br&gt;
crawled in the same language. Half the site vanishes from the index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Routes are translated.&lt;/strong&gt; &lt;code&gt;/es/sobre-mi&lt;/code&gt;, not &lt;code&gt;/es/about&lt;/code&gt;. People searching in&lt;br&gt;
Spanish type in Spanish, and the URL is part of what they see in the result.&lt;br&gt;
It's more work — a static folder can only have one name — and most of this post&lt;br&gt;
is about paying that cost once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One explicit default language.&lt;/strong&gt; The root &lt;code&gt;/&lt;/code&gt; goes to &lt;code&gt;/en&lt;/code&gt;, and the&lt;br&gt;
&lt;code&gt;x-default&lt;/code&gt; in &lt;code&gt;hreflang&lt;/code&gt; points to English. Spanish isn't a second-class&lt;br&gt;
translation: it's the language the content is written in first. But the&lt;br&gt;
audience reaching the site from outside the Dominican Republic searches in&lt;br&gt;
English.&lt;/p&gt;
&lt;h2&gt;
  
  
  The language is the first segment
&lt;/h2&gt;

&lt;p&gt;The whole site hangs from &lt;code&gt;app/[locale]&lt;/code&gt;, and that segment holds the &lt;strong&gt;root&lt;br&gt;
layout&lt;/strong&gt;, the one that writes &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;. That matters for a specific reason:&lt;br&gt;
the &lt;code&gt;lang&lt;/code&gt; attribute has to be in the served HTML, not patched in later with&lt;br&gt;
JavaScript. It's what screen readers and search engines read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&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="nx"&gt;PUBLISHED_LOCALES&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;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Only published languages exist: `/fr` or `/xx/contacto` are a straight&lt;/span&gt;
&lt;span class="c1"&gt;// 404, never rendered on demand.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamicParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dynamicParams = false&lt;/code&gt; makes any language not on the list an immediate 404.&lt;br&gt;
Without that line, Next.js would try to render &lt;code&gt;/fr&lt;/code&gt; on demand and cache it.&lt;/p&gt;

&lt;p&gt;There's a price: with one root layout per language, switching language reloads&lt;br&gt;
the whole document. I accepted it. It's something you do once per visit, and in&lt;br&gt;
exchange each language's HTML is right from the first byte. The other&lt;br&gt;
consequence is that a route outside any language has no layout to render its&lt;br&gt;
404; that's what &lt;code&gt;app/global-not-found.tsx&lt;/code&gt; is for, currently enabled with&lt;br&gt;
&lt;code&gt;experimental.globalNotFound&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Translated routes with one dynamic folder
&lt;/h2&gt;

&lt;p&gt;The obvious way to have &lt;code&gt;/es/proyectos&lt;/code&gt; and &lt;code&gt;/en/projects&lt;/code&gt; is two folders. That&lt;br&gt;
works for a one-off page — privacy has &lt;code&gt;privacidad/&lt;/code&gt; and &lt;code&gt;privacy/&lt;/code&gt;, each only in&lt;br&gt;
its own language — but it doesn't scale: every child page would have to be&lt;br&gt;
duplicated, and its code with it.&lt;/p&gt;

&lt;p&gt;The usual alternative, a rewrite (in &lt;code&gt;next.config&lt;/code&gt; or middleware), has a trap:&lt;br&gt;
&lt;code&gt;usePathname&lt;/code&gt; returns one route on the server and another on the client, and&lt;br&gt;
any component that marks "you are here" ends up out of sync.&lt;/p&gt;

&lt;p&gt;So sections are a dynamic segment: &lt;code&gt;app/[locale]/[mundo]&lt;/code&gt;, with children in&lt;br&gt;
&lt;code&gt;[mundo]/[sub]&lt;/code&gt; and &lt;code&gt;[mundo]/[sub]/[objeto]&lt;/code&gt;. Each section's slug lives in the&lt;br&gt;
frontmatter of its content, per language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# content/es/worlds/endurance.mdx&lt;/span&gt;
&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;endurance&lt;/span&gt;
&lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proyectos&lt;/span&gt;

&lt;span class="c1"&gt;# content/en/worlds/endurance.mdx&lt;/span&gt;
&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;endurance&lt;/span&gt;
&lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;projects&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two versions are joined by &lt;code&gt;id&lt;/code&gt;, never by slug. Segments that don't belong&lt;br&gt;
to any section — &lt;code&gt;servicios&lt;/code&gt;/&lt;code&gt;services&lt;/code&gt;, &lt;code&gt;gracias&lt;/code&gt;/&lt;code&gt;thanks&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;observatorio&lt;/code&gt;/&lt;code&gt;observatory&lt;/code&gt; — live in a separate module with no dependencies,&lt;br&gt;
so client code can read them too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PATH_SEGMENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;observatory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;observatorio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;observatory&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;thanks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gracias&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;thanks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;servicios&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;services&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;privacy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;privacidad&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;privacy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;blog&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blog&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;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;satisfies&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every dynamic page declares its &lt;code&gt;generateStaticParams&lt;/code&gt; in both languages and&lt;br&gt;
also sets &lt;code&gt;dynamicParams = false&lt;/code&gt;. The result: the whole site is generated at&lt;br&gt;
build time, and &lt;code&gt;/en/proyectos&lt;/code&gt; — the Spanish slug under English — is a 404, not&lt;br&gt;
a duplicate page.&lt;/p&gt;
&lt;h2&gt;
  
  
  Name pages, not URLs
&lt;/h2&gt;

&lt;p&gt;This is the piece that makes everything else easy. Nowhere in the code is a page&lt;br&gt;
named by its route. It's named by what it &lt;strong&gt;is&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PageRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;home&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;world&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorldId&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;project&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProjectId&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;services&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;observatory&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorldId&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blog&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;article&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArticleId&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;privacy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One function turns a &lt;code&gt;PageRef&lt;/code&gt; into its route for a language, and another&lt;br&gt;
returns the same page in every published language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pageAlternates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PageRef&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;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;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;PUBLISHED_LOCALES&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;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;pagePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)]),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;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;The language switcher, each page's &lt;code&gt;hreflang&lt;/code&gt;, the sitemap and internal links&lt;br&gt;
all draw from that function. Nobody builds a URL by hand. If &lt;code&gt;/es/proyectos&lt;/code&gt;&lt;br&gt;
were renamed &lt;code&gt;/es/trabajo&lt;/code&gt; tomorrow, one line of frontmatter changes and&lt;br&gt;
everything else — header links and sitemap included — follows.&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;PageRef&lt;/code&gt; is a discriminated union, the &lt;code&gt;switch&lt;/code&gt; that walks it is&lt;br&gt;
exhaustive: adding a new page without saying what its route is becomes a&lt;br&gt;
TypeScript error, not a broken link in production.&lt;/p&gt;
&lt;h2&gt;
  
  
  hreflang and x-default
&lt;/h2&gt;

&lt;p&gt;Without &lt;code&gt;hreflang&lt;/code&gt;, Google sees &lt;code&gt;/es/sobre-mi&lt;/code&gt; and &lt;code&gt;/en/about&lt;/code&gt; as two pages on&lt;br&gt;
the same topic and picks one, or alternates between them. With it, it knows&lt;br&gt;
they're the same page and shows each person the one in their language.&lt;/p&gt;

&lt;p&gt;Each page's metadata comes from the same source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pageAlternatesMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PageRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Locale&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;alternates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pageAlternates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;DEFAULT_LOCALE&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each page states its canonical URL — its own, in its language — and declares&lt;br&gt;
its siblings. &lt;code&gt;x-default&lt;/code&gt; tells the search engine what to show someone who&lt;br&gt;
searches in neither language: English.&lt;/p&gt;

&lt;p&gt;Two details that are easy to forget:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The relationship must be reciprocal.&lt;/strong&gt; If the English version points to the
Spanish one, the Spanish one has to point back. Since both come from the same
function, they can't disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Graph has a language too.&lt;/strong&gt; Each page declares &lt;code&gt;og:locale&lt;/code&gt; (&lt;code&gt;en_US&lt;/code&gt; or
&lt;code&gt;es_DO&lt;/code&gt;) and the other one as &lt;code&gt;og:locale:alternate&lt;/code&gt;, so a social preview
knows what it is.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The sitemap says it as well
&lt;/h2&gt;

&lt;p&gt;Search engines read the sitemap before crawling, so the language relationship&lt;br&gt;
goes there too. Next.js generates each entry's &lt;code&gt;xhtml:link rel="alternate"&lt;/code&gt;&lt;br&gt;
from &lt;code&gt;alternates.languages&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sitemap&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;MetadataRoute&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Sitemap&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;PAGES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;changeFrequency&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;alternates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pageAlternates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&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;languages&lt;/span&gt; &lt;span class="o"&gt;=&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;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&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;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alternates&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;locale&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;absoluteUrl&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="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;PUBLISHED_LOCALES&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;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;absoluteUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
      &lt;span class="nx"&gt;changeFrequency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;languages&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One deliberate choice: the sitemap has &lt;strong&gt;no &lt;code&gt;lastModified&lt;/code&gt;&lt;/strong&gt;. Stamping every&lt;br&gt;
URL with the build date would claim a change that didn't happen, and search&lt;br&gt;
engines learn to ignore an unreliable &lt;code&gt;lastmod&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The language switcher is made of links
&lt;/h2&gt;

&lt;p&gt;The EN | ES switcher in the header isn't a button that flips some state. It's&lt;br&gt;
two &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; links to the same page in the other language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;
  &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;languages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;hrefLang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;aria-current&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;LANGUAGE_COOKIE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;; path=/; max-age=31536000; samesite=lax`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That brings several things a button doesn't: it works without JavaScript, it&lt;br&gt;
can be opened in another tab, search engines follow it and screen readers&lt;br&gt;
announce it for what it is. Its accessible name is the language's name in that&lt;br&gt;
language — "English", "Español" — because that's how people recognise it.&lt;/p&gt;

&lt;p&gt;They're plain &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; rather than Next.js &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt; on purpose: switching language&lt;br&gt;
switches root layout, and Next.js would reload the document anyway. A &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt;&lt;br&gt;
would only add a useless prefetch.&lt;/p&gt;
&lt;h2&gt;
  
  
  The root: a redirect, not middleware
&lt;/h2&gt;

&lt;p&gt;All JavaScript adds to the switcher is remembering the choice in a cookie. What&lt;br&gt;
for? The root. Someone who chose Spanish and comes back through&lt;br&gt;
&lt;code&gt;jonasjavier.dev&lt;/code&gt; should land in Spanish. Two static redirects in&lt;br&gt;
&lt;code&gt;next.config.ts&lt;/code&gt; handle that, without middleware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;redirects&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="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;has&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cookie&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LANGUAGE_COOKIE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/es&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;permanent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/en&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permanent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are temporary (307): the root hasn't moved, it just depends on a&lt;br&gt;
preference. And &lt;code&gt;Accept-Language&lt;/code&gt; isn't consulted, for the reasons at the top:&lt;br&gt;
a home page that changes with who's asking can't be cached or linked safely,&lt;br&gt;
and the search engine always sees the English one, which is what &lt;code&gt;x-default&lt;/code&gt;&lt;br&gt;
declares.&lt;/p&gt;

&lt;p&gt;With no middleware there's no code running before every request, and pages are&lt;br&gt;
still generated at build time.&lt;/p&gt;
&lt;h2&gt;
  
  
  Interface text lives with its component
&lt;/h2&gt;

&lt;p&gt;There are two kinds of text. &lt;strong&gt;Content&lt;/strong&gt; — each section's prose, the case&lt;br&gt;
studies, these posts — lives in &lt;code&gt;content/es&lt;/code&gt; and &lt;code&gt;content/en&lt;/code&gt; as MDX, compiled&lt;br&gt;
by Velite. &lt;strong&gt;Interface text&lt;/strong&gt; — buttons, labels, states — lives in the&lt;br&gt;
component itself, in an object with both languages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;defineCopy&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;es&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="nl"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NoInfer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&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;table&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;COPY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineCopy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Idioma&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Language&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NoInfer&lt;/code&gt; is the important part. Without it, TypeScript would infer the type&lt;br&gt;
from the union of both objects, and a key forgotten in English would slip&lt;br&gt;
through. With it, Spanish fixes the type and English has to match it: a missing&lt;br&gt;
key fails the typecheck.&lt;/p&gt;

&lt;p&gt;For content, the guarantee is in the build. Velite checks that every piece&lt;br&gt;
exists exactly once in each published language and that no two pieces share a&lt;br&gt;
slug in the same language. If a section's translation is missing, the site&lt;br&gt;
doesn't ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  What isn't translated word for word
&lt;/h2&gt;

&lt;p&gt;The English titles and descriptions aren't translations of the Spanish ones:&lt;br&gt;
they're written for English searches. «Desarrollador full-stack en República&lt;br&gt;
Dominicana» doesn't become "Developer full-stack in Dominican Republic": in&lt;br&gt;
English people search for "Full-Stack Developer", in a different order.&lt;/p&gt;

&lt;p&gt;Structured data follows the same logic: the person behind the site has a single&lt;br&gt;
&lt;code&gt;@id&lt;/code&gt; in both languages — it's the same person — but their job title and&lt;br&gt;
description are in the page's language.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's tested
&lt;/h2&gt;

&lt;p&gt;A Playwright suite covers what can break: that the root goes to English and,&lt;br&gt;
with the cookie, to Spanish; that each pair of routes has its &lt;code&gt;lang&lt;/code&gt;, canonical&lt;br&gt;
and crossed &lt;code&gt;hreflang&lt;/code&gt;; that a route in one language is a 404 in the other; that&lt;br&gt;
the switcher leads to the same page and remembers the choice; that English pages&lt;br&gt;
speak English; and that the contact form validates in the page's language. And&lt;br&gt;
a unit test requires every internal link in these posts to point to a page that&lt;br&gt;
exists, in the post's language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Switch language in the header from any page: you land on the same page in the&lt;br&gt;
other language, not on the home page. If you're building something that needs&lt;br&gt;
to speak two languages, here's &lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work&lt;/a&gt;. And if&lt;br&gt;
you're curious about the 3D side of the site, start with&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/blog/how-i-built-a-black-hole-in-webgl" rel="noopener noreferrer"&gt;the black hole&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasjavier.dev/en/blog/bilingual-next-js-site-without-middleware" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;, next to the &lt;a href="https://jonasjavier.dev/en/blog" rel="noopener noreferrer"&gt;blog&lt;/a&gt;. I'm Jonás Javier Encarnación, a full-stack developer and UX/UI designer in Santo Domingo, Dominican Republic: &lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>i18n</category>
      <category>seo</category>
      <category>react</category>
    </item>
    <item>
      <title>A 3D portfolio Google can read and a phone can run</title>
      <dc:creator>Jonás Javier Encarnación</dc:creator>
      <pubDate>Fri, 02 Oct 2026 21:23:28 +0000</pubDate>
      <link>https://dev.to/jonasjavier/a-3d-portfolio-google-can-read-and-a-phone-can-run-2jlo</link>
      <guid>https://dev.to/jonasjavier/a-3d-portfolio-google-can-read-and-a-phone-can-run-2jlo</guid>
      <description>&lt;p&gt;3D portfolios have a bad reputation for two reasons, and both are fair. To a&lt;br&gt;
search engine they're usually an empty &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;: all the text lives inside the&lt;br&gt;
scene and there's nothing to index. And on a phone they're usually a black&lt;br&gt;
screen that takes a while to start, heats up and drains the battery.&lt;/p&gt;

&lt;p&gt;This site has a black hole traced pixel by pixel in a shader, with six bodies&lt;br&gt;
around it. This post covers the rules and pieces that keep it readable for&lt;br&gt;
Google and usable on a phone, with the measured numbers — and with what still&lt;br&gt;
isn't solved.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule: the scene is never the content
&lt;/h2&gt;

&lt;p&gt;Before a single line of Three.js, one non-negotiable rule was written down:&lt;br&gt;
&lt;strong&gt;every page's served HTML contains the real text, without JavaScript&lt;/strong&gt;. The&lt;br&gt;
scene sits behind, decorates and orients, but it's not where the meaning&lt;br&gt;
lives.&lt;/p&gt;

&lt;p&gt;In practice that comes down to four things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; is &lt;code&gt;aria-hidden&lt;/code&gt;, fixed behind the page
(&lt;code&gt;position: fixed; z-index: -1; pointer-events: none&lt;/code&gt;) and contains not one
word.&lt;/li&gt;
&lt;li&gt;On the home page, the name and role are in a real &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;, and the six
destinations are six real &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; links with a minimum 44 px touch target.
The clickable "bodies" over the scene are copies hidden from screen readers
(&lt;code&gt;aria-hidden&lt;/code&gt;, out of the tab order): the link that counts is the text one.&lt;/li&gt;
&lt;li&gt;On the server, the effect level is always flat. Next.js renders the HTML
without knowing whether the visitor has a GPU, so that HTML never depends on
WebGL.&lt;/li&gt;
&lt;li&gt;The canvas must not be the largest contentful paint (LCP): by design, what
paints first is light HTML and CSS.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What someone without JavaScript sees
&lt;/h2&gt;

&lt;p&gt;The most direct test is to turn JavaScript off and load the home page. This is&lt;br&gt;
what you get:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkwyg0ydzwxhd5jcmqxiy.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkwyg0ydzwxhd5jcmqxiy.webp" alt="The site's home page with JavaScript disabled: Gargantua, the planets and the ships drawn in SVG, and the six destinations as text along the bottom." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The home page with JavaScript turned off. The system is drawn with SVG and CSS, and the six destinations are text links a search engine can follow.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It isn't an "enable JavaScript" screen: it's the site. The links work, the text&lt;br&gt;
is there and the composition is the same. It's also, almost pixel for pixel,&lt;br&gt;
what a visitor sees without WebGL2 or with effects turned off.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three levels: flat, orbit and deep
&lt;/h2&gt;

&lt;p&gt;Having WebGL doesn't mean a browser should run the scene. A device with a&lt;br&gt;
software GPU takes seconds to draw a single frame of the raymarch: measured on&lt;br&gt;
SwiftShader, one frame blocks the main thread for about 6 seconds. So the site&lt;br&gt;
chooses between three levels — &lt;code&gt;flat&lt;/code&gt;, &lt;code&gt;orbit&lt;/code&gt; and &lt;code&gt;deep&lt;/code&gt; — with a pure function&lt;br&gt;
that takes signals and returns a verdict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasWebGL2&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sin-webgl2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;canOverride&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;weakDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CapabilitySignals&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CapabilityVerdict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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="nf"&gt;isSoftwareRenderer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renderer&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpu-por-software&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;canOverride&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="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;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;slow-2g&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2g&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;red-lenta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;canOverride&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="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;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deviceMemory&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deviceMemory&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;memoria-corta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;canOverride&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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 few details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A software GPU is detected, not guessed.&lt;/strong&gt; A throwaway canvas reads the
renderer name through &lt;code&gt;WEBGL_debug_renderer_info&lt;/code&gt; and checks it against
&lt;code&gt;swiftshader&lt;/code&gt;, &lt;code&gt;llvmpipe&lt;/code&gt;, &lt;code&gt;softpipe&lt;/code&gt; and so on. It then releases that
context with &lt;code&gt;WEBGL_lose_context&lt;/code&gt; and caches the answer for the session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A missing signal doesn't count against you.&lt;/strong&gt; Safari exposes neither
&lt;code&gt;deviceMemory&lt;/code&gt; nor the connection type; treating that absence as "weak
device" would leave every iPhone without the scene.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The visitor decides.&lt;/strong&gt; The motion button, bottom right, turns everything
off; and whoever turns it on deliberately gets the scene even if the device
looks weak. The code reserves the top level, &lt;code&gt;deep&lt;/code&gt; (more steps per pixel in
the black hole), for desktops with green signals: a screen at least 1100 px
wide, a fine pointer and eight cores or eight gigabytes, or no data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The flat level isn't a punishment
&lt;/h2&gt;

&lt;p&gt;The flat level isn't an apologetic error page. It's an atlas drawn with SVG and&lt;br&gt;
CSS with the same composition as the scene: Gargantua with its disk, the&lt;br&gt;
planets, the ships and the tesseract — the last one computed with the same&lt;br&gt;
function as the 3D version. Each body's coordinates are hand-directed for three&lt;br&gt;
screen formats (wide, portrait and short).&lt;/p&gt;

&lt;p&gt;And it downloads &lt;strong&gt;zero bytes of 3D&lt;/strong&gt;: three.js and the scene are only&lt;br&gt;
requested at &lt;code&gt;orbit&lt;/code&gt; or &lt;code&gt;deep&lt;/code&gt;. If the browser loses the WebGL context mid-visit&lt;br&gt;
or the load fails, the scene drops to flat and doesn't try again during that&lt;br&gt;
visit: a flat site beats one that flickers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh57ybme55jh2t617b9q2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh57ybme55jh2t617b9q2.webp" alt="The home page with the WebGL scene: Gargantua traced in real time with its copper disk, and Miller, Edmunds, the Endurance and the Ranger around it." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same home page at the orbit level: the black hole is traced in a shader every frame. On top, the same HTML as the flat version.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Load the 3D once it's out of the way
&lt;/h2&gt;

&lt;p&gt;On the home page the scene is the star and loads as soon as the page is ready.&lt;br&gt;
On every other page — where the content covers the scene — there's no point in&lt;br&gt;
it competing with the main image and with hydration. There it waits until the&lt;br&gt;
page has loaded and the browser is idle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;whenIdle&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestIdleCallback&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&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;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;whenIdle&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;load&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;whenIdle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Safari has no &lt;code&gt;requestIdleCallback&lt;/code&gt;; there it fires right after &lt;code&gt;load&lt;/code&gt;. A fixed&lt;br&gt;
delay instead used to collide with the user's first interaction. Once reached,&lt;br&gt;
the state holds for the whole visit: navigating doesn't wait again.&lt;/p&gt;

&lt;p&gt;Even the level decision waits. That WebGL probe costs about 250 ms on a Chrome&lt;br&gt;
without a GPU — the one PageSpeed uses — and Lighthouse multiplies CPU by four,&lt;br&gt;
so it was a task of nearly a second on every page. three.js comes in through a&lt;br&gt;
dynamic &lt;code&gt;import()&lt;/code&gt; and never in the initial load.&lt;/p&gt;
&lt;h2&gt;
  
  
  Compile shaders without freezing the page
&lt;/h2&gt;

&lt;p&gt;The black hole shader is about 140 KB of GLSL. Compiling it all at once&lt;br&gt;
blocked the page for 2.4 to 2.7 seconds when arriving on the home page. The fix&lt;br&gt;
was &lt;code&gt;compileAsync&lt;/code&gt;, which uses the &lt;code&gt;KHR_parallel_shader_compile&lt;/code&gt; extension to&lt;br&gt;
compile in parallel before the first frame:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KHR_parallel_shader_compile&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;compiled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compileAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;marchScene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quadCamera&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compileAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bodyScene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bodyCamera&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;canAccumulate&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compileAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;displayScene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quadCamera&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;compiled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startLoop&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;startLoop&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;Measured with mobile Lighthouse on a machine with a real integrated GPU: Total&lt;br&gt;
Blocking Time went from &lt;strong&gt;7.95 s to about 2.0 s&lt;/strong&gt;, Time to Interactive from 12.0&lt;br&gt;
to 7.5 s and Speed Index from 7.1 to 4.1 s. Firefox lacks the extension and&lt;br&gt;
keeps the old path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Images: what gets painted, not what fits
&lt;/h2&gt;

&lt;p&gt;Half of the site's performance gains had nothing to do with 3D. A&lt;br&gt;
page-by-page Lighthouse review found three classic problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A lying &lt;code&gt;sizes&lt;/code&gt;.&lt;/strong&gt; The photos in the About constellation, painted at about
114 px on a phone (the portrait at 174), declared a much larger size, and the
phone downloaded the 640 and 960 px versions: about 700 KB for a handful of
thumbnails. With &lt;code&gt;sizes&lt;/code&gt; set to what's actually painted (120 and 180 px), that
stopped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone crops.&lt;/strong&gt; Header images have their own portrait crop: 92 KB instead of
214 KB, and 53 KB instead of 195 KB. Each one is preloaded with
&lt;code&gt;fetchPriority: "high"&lt;/code&gt; and its own &lt;code&gt;media&lt;/code&gt;, so the phone doesn't download the
desktop version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steps and quality.&lt;/strong&gt; An 800 px step and WebP quality 80 nearly halved the
image weight of four pages: from 1313 to 725 KB, 1030 to 549, 756 to 407 and
601 to 327.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A phone isn't a small desktop
&lt;/h2&gt;

&lt;p&gt;On a phone the scene starts at one render pixel per screen point. From there, a&lt;br&gt;
small governor measures the real frame pace and raises resolution to 1.25 and&lt;br&gt;
1.5 only if the device can sustain it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constant&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Steps&lt;/td&gt;
&lt;td&gt;1 → 1.25 → 1.5 px per point&lt;/td&gt;
&lt;td&gt;Never above &lt;code&gt;devicePixelRatio&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Window&lt;/td&gt;
&lt;td&gt;45 frames&lt;/td&gt;
&lt;td&gt;~0.75 s at 60 Hz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Settling&lt;/td&gt;
&lt;td&gt;30 frames&lt;/td&gt;
&lt;td&gt;Discarded after each change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;p75 ≤ 18.5 ms&lt;/td&gt;
&lt;td&gt;Step up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;td&gt;p75 ≥ 26 ms&lt;/td&gt;
&lt;td&gt;Step down and close the ceiling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tolerance&lt;/td&gt;
&lt;td&gt;×1.15 over its best pace&lt;/td&gt;
&lt;td&gt;90 and 120 Hz screens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p75&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sorted&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;span class="nx"&gt;pace&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;pace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p75&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;held&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p75&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;pace&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;PACE_TOLERANCE&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;index&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p75&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;SLOW_MS&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;index&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;ceiling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// undo and close the ceiling&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;held&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p75&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;FAST_MS&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;ceiling&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;index&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="nf"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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 first version compared against a fixed 60 fps and got 90 and 120 Hz screens&lt;br&gt;
wrong: 60 fps counted as "headroom" while the phone was dropping frames. Now&lt;br&gt;
each device is compared with &lt;strong&gt;its own best pace&lt;/strong&gt;. And a step that can't be&lt;br&gt;
sustained isn't tried again: the phone doesn't flip between two resolutions.&lt;br&gt;
The governor pauses during transitions, with the tab hidden and while the scene&lt;br&gt;
is covered, so it never measures the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never two contexts drawing
&lt;/h2&gt;

&lt;p&gt;Several pages have their own WebGL: Miller's ocean, the wormhole on Contact,&lt;br&gt;
the Observatory. If the background scene kept drawing behind them, the phone&lt;br&gt;
would render two scenes at once and only show one.&lt;/p&gt;

&lt;p&gt;On pages that cover it, the scene stops requesting frames. The Observatory goes&lt;br&gt;
further: its canvas fills the screen for the whole visit, so there the&lt;br&gt;
persistent scene &lt;strong&gt;releases&lt;/strong&gt; its WebGL context and video memory, and recreates&lt;br&gt;
it on the way out. Pausing the loop wasn't enough: the context stayed alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure without cheating
&lt;/h2&gt;

&lt;p&gt;One more project rule: code whose only purpose is to change an audit result is&lt;br&gt;
forbidden. No sniffing Lighthouse's user agent to serve it a lighter page.&lt;/p&gt;

&lt;p&gt;Lighthouse is audited with &lt;code&gt;?no3d=1&lt;/code&gt;, which is exactly the same as pressing the&lt;br&gt;
motion button: any visitor can type it. Continuous integration runs Lighthouse&lt;br&gt;
three times on the home page in each language and takes the median, with&lt;br&gt;
thresholds that fail the build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every category ≥ 0.9;&lt;/li&gt;
&lt;li&gt;CLS ≤ 0.1;&lt;/li&gt;
&lt;li&gt;Total Blocking Time ≤ 300 ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The improvements were measured with local Lighthouse against &lt;code&gt;next start&lt;/code&gt;,&lt;br&gt;
three runs, median, always A/B against the previous version. A local machine's&lt;br&gt;
absolute numbers aren't PageSpeed's, but the difference between A and B is&lt;br&gt;
reliable. Some results from that work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Projects&lt;/td&gt;
&lt;td&gt;66&lt;/td&gt;
&lt;td&gt;79–82 (TBT 762 → ~250 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Creativity&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Experiments&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;81–92&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Education&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last full measurement of the home page without the scene (&lt;code&gt;?no3d=1&lt;/code&gt;,&lt;br&gt;
locally, three runs) scored 98 for performance and 100 for accessibility, best&lt;br&gt;
practices and SEO, with a median LCP of 2.32 s and a TBT of 66.5 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What isn't solved yet
&lt;/h2&gt;

&lt;p&gt;It would be dishonest to stop here. Two things still weigh:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The framework's JavaScript.&lt;/strong&gt; The Next.js 16 plus React 19 baseline is
already around 146 KB compressed before writing a line, and on a simulated
phone that code takes nearly two seconds of CPU. LCP is tied to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server.&lt;/strong&gt; With no CDN in front, time to first byte is between 300 and
600 ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither is fixed with more tricks in the scene.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of SEO, briefly
&lt;/h2&gt;

&lt;p&gt;Everything above is what makes the page &lt;strong&gt;readable&lt;/strong&gt;. What helps it be&lt;br&gt;
understood is the usual, done properly: structured data for the person, the site&lt;br&gt;
and each piece of work; a sitemap with images and the relationship between&lt;br&gt;
languages; and a canonical and &lt;code&gt;hreflang&lt;/code&gt; on every page. The languages part has&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/blog/bilingual-next-js-site-without-middleware" rel="noopener noreferrer"&gt;its own post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Open the &lt;a href="https://jonasjavier.dev/en" rel="noopener noreferrer"&gt;home page&lt;/a&gt; and press the motion button, bottom right: the scene&lt;br&gt;
switches off and the flat atlas remains, with the same content. If you want to&lt;br&gt;
see the black hole up close, it's in the&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/experiments/observatory/gargantua" rel="noopener noreferrer"&gt;Observatory&lt;/a&gt;, and here's&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/blog/how-i-built-a-black-hole-in-webgl" rel="noopener noreferrer"&gt;how it's built&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have a product that needs to look good and load fast at the same time,&lt;br&gt;
here's &lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasjavier.dev/en/blog/3d-portfolio-webgl-seo-and-performance" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;, next to the &lt;a href="https://jonasjavier.dev/en/blog" rel="noopener noreferrer"&gt;blog&lt;/a&gt;. I'm Jonás Javier Encarnación, a full-stack developer and UX/UI designer in Santo Domingo, Dominican Republic: &lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>performance</category>
      <category>seo</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>How I drew a 4D tesseract in Three.js</title>
      <dc:creator>Jonás Javier Encarnación</dc:creator>
      <pubDate>Fri, 02 Oct 2026 21:19:14 +0000</pubDate>
      <link>https://dev.to/jonasjavier/how-i-drew-a-4d-tesseract-in-threejs-1npc</link>
      <guid>https://dev.to/jonasjavier/how-i-drew-a-4d-tesseract-in-threejs-1npc</guid>
      <description>&lt;p&gt;The Tesseract is the Experiments world on this site: a glass hypercube floating&lt;br&gt;
next to Gargantua that you can look at full screen in the&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/experiments/observatory/tesseract" rel="noopener noreferrer"&gt;Observatory&lt;/a&gt;. It isn't an animated 3D&lt;br&gt;
model. It's a four-dimensional object that rotates in four dimensions and is&lt;br&gt;
projected, every frame, into the three a screen can show. This post covers how&lt;br&gt;
it's built, from the sixteen vertices to the stroke of light that traces it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What a tesseract is
&lt;/h2&gt;

&lt;p&gt;A square is a segment dragged in a new direction. A cube is a square dragged in&lt;br&gt;
a direction perpendicular to the two it already has. A tesseract is the same&lt;br&gt;
move one step further: a cube dragged in a fourth direction, perpendicular to&lt;br&gt;
the three of our space, which I'll call &lt;strong&gt;W&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Counting it is easy even if picturing it isn't. Every time you drag a shape,&lt;br&gt;
its vertices double and a new edge appears for each original vertex. A square&lt;br&gt;
has 4 vertices and 4 edges; a cube, 8 and 12; a tesseract, &lt;strong&gt;16 vertices and 32&lt;br&gt;
edges&lt;/strong&gt;, with 24 square faces and 8 cubic cells. What you see on screen — a&lt;br&gt;
cube inside a cube, joined at the corners — is that object's shadow in three&lt;br&gt;
dimensions, the same way a drawing of a cube on paper is its shadow in two.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sixteen vertices from four bits
&lt;/h2&gt;

&lt;p&gt;The cleanest way to generate a tesseract is to think in bits rather than&lt;br&gt;
coordinates. Each vertex is a number from 0 to 15, and each of its four bits&lt;br&gt;
says whether it sits on the negative or positive side of an axis: bit 0 is X,&lt;br&gt;
1 is Y, 2 is Z and 3 is W. The coordinates are ±0.5.&lt;/p&gt;

&lt;p&gt;With that numbering, two vertices share an edge exactly when their numbers&lt;br&gt;
differ in a single bit. XOR finds them without searching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&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="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;vertex&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;vertex&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;vertex&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="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;axis&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;axis&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;axis&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;other&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vertex&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;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;axis&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;vertex&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;other&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;vertex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;other&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;Out come the 32 edges, eight per axis. The eight along W are the ones joining&lt;br&gt;
the inner cube to the outer one, and they're the most interesting to watch:&lt;br&gt;
they run in a direction that doesn't exist in the room you're sitting in.&lt;/p&gt;
&lt;h2&gt;
  
  
  One stroke: the Eulerian circuit
&lt;/h2&gt;

&lt;p&gt;On the site the tesseract doesn't appear all at once: a point of light draws it&lt;br&gt;
edge by edge, like a pen. For that pen never to lift or jump from one vertex to&lt;br&gt;
another, the path has to cover all 32 edges exactly once and return to its&lt;br&gt;
start. That's an &lt;strong&gt;Eulerian circuit&lt;/strong&gt;, and graph theory says when one exists:&lt;br&gt;
when every vertex has even degree.&lt;/p&gt;

&lt;p&gt;In a tesseract every vertex touches four edges, one per axis. Four is even, so&lt;br&gt;
the circuit exists, and Hierholzer's algorithm builds it with a stack in a&lt;br&gt;
dozen lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;edges&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&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;stack&lt;/span&gt; &lt;span class="o"&gt;=&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;circuit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;vertex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findIndex&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;b&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="nx"&gt;remaining&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;amp;&amp;amp;&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;vertex&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;vertex&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;index&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="nx"&gt;circuit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&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="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&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;vertex&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&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="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;circuit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs once when the module loads, and a test checks the three things that&lt;br&gt;
matter: the stroke is continuous, no edge repeats and every vertex keeps degree&lt;br&gt;
four. The point of light covers all 32 edges every 18 seconds and leaves a&lt;br&gt;
trail that fades over about thirteen of them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rotating in four dimensions
&lt;/h2&gt;

&lt;p&gt;In three dimensions we say something rotates "around an axis". In four that&lt;br&gt;
sentence stops working: what makes sense is rotating &lt;strong&gt;in a plane&lt;/strong&gt;. A rotation&lt;br&gt;
in the XY plane moves X and Y and leaves Z and W alone. There are six possible&lt;br&gt;
planes (XY, XZ, XW, YZ, YW, ZW), and the three that don't touch W are ordinary&lt;br&gt;
rotations: they show nothing new.&lt;/p&gt;

&lt;p&gt;So the site's tesseract only rotates in the three planes that mix one of our&lt;br&gt;
axes with W: &lt;strong&gt;XW, YW and ZW&lt;/strong&gt;. Those are the rotations that make the inner cube&lt;br&gt;
grow, pass through the outer one and come out the other side. Each is a&lt;br&gt;
two-coordinate rotation, applied in sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rhythm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&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;angles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.19&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.137&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.48&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.083&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.16&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="c1"&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;axis&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;axis&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;axis&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;a&lt;/span&gt; &lt;span class="o"&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;axis&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="nx"&gt;p&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;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;axis&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;cos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&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="nx"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;axis&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="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="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;]&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="nx"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;axis&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 three speeds (0.19, 0.137 and 0.083 radians per unit of time) have no&lt;br&gt;
simple ratio between them, so the shape doesn't repeat the same sequence of&lt;br&gt;
poses every few seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5b021o6rh5rdn4ugvr4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff5b021o6rh5rdn4ugvr4.webp" alt="Six frames of the same tesseract at different moments of its rotation: sometimes a cube inside another, sometimes a flattened shape." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Six moments of one rotation. Nothing deforms in four dimensions: what changes is the shadow the object casts in three.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two decisions in that function aren't obvious, and both matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time is absolute.&lt;/strong&gt; The pose is computed from elapsed seconds, not by adding&lt;br&gt;
an increment every frame. If it added increments, a 120 Hz screen would spin&lt;br&gt;
the shape twice as fast as a 60 Hz one. With absolute time, the same moment&lt;br&gt;
gives the same pose on any device, and a test checks it up to six hours in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The clock has rhythm.&lt;/strong&gt; At a constant speed, after a few seconds the brain&lt;br&gt;
decides "fine, it's spinning" and stops looking at the shape. And the path&lt;br&gt;
isn't even: some poses read instantly — the cube inside the cube — and some&lt;br&gt;
stretches flatten the projection into confusion. So time is warped with two&lt;br&gt;
harmonics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rhythm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&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;seconds&lt;/span&gt;
    &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.3151&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="mi"&gt;2&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="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;7.5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;18&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.2722&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="mi"&gt;2&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="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;3.5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;9&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 measured result: the shape moves at 0.71 of its average speed on the&lt;br&gt;
readable poses, 1.21 on the confusing ones, and never drops below 0.7. Since&lt;br&gt;
the speed never reaches zero, the clock never runs backwards: an animation that&lt;br&gt;
rewinds half a second reads as a bug, not as rhythm. With a single harmonic the&lt;br&gt;
peaks and troughs landed nine seconds apart and missed the poses that needed&lt;br&gt;
slowing down; the second one, at half the period, moves them into place.&lt;/p&gt;
&lt;h2&gt;
  
  
  From four dimensions to three: perspective from W
&lt;/h2&gt;

&lt;p&gt;After rotating, each vertex still has four coordinates. To see it you have to&lt;br&gt;
project it, and the projection is the same idea as an ordinary camera's&lt;br&gt;
perspective, one dimension up: a camera at W = 1.65 looking at the origin.&lt;br&gt;
Whatever is closer to it in W looks bigger.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;perspective&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.65&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.65&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;p&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="kd"&gt;const&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;p&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;perspective&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&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;perspective&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&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="nx"&gt;perspective&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole secret of the "cube inside a cube": there aren't two cubes of&lt;br&gt;
different sizes, there are two identical cells at different distances from the&lt;br&gt;
camera in the fourth dimension. Then a fixed pose presents it diagonally in 3D&lt;br&gt;
(0.6, 0.45 and 0.28 radians), and each sample is re-centred and scaled so the&lt;br&gt;
farthest vertex always sits 1.5 units from the centre. Without that&lt;br&gt;
normalisation the shape "breathes" as it rotates, and the click area and the&lt;br&gt;
brackets that target it on the map would move with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not a cage
&lt;/h2&gt;

&lt;p&gt;The first version with all of this working was unreadable. Thirty-two edges of&lt;br&gt;
the same thickness and brightness don't read as a four-dimensional object: they&lt;br&gt;
read as a wire cage. Raising the exposure didn't help; it just made a brighter&lt;br&gt;
cage.&lt;/p&gt;

&lt;p&gt;What fixed it was &lt;strong&gt;hierarchy&lt;/strong&gt;. The same perspective that sets the size also&lt;br&gt;
says how close each vertex is in W, and that value, normalised from 0 to 1 every&lt;br&gt;
frame, drives the thickness and light of each edge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each tube's radius goes from 0.58 to 1.42 times the base depending on its
depth in W, so the near cell is thick and the far one thin.&lt;/li&gt;
&lt;li&gt;In the shader, brightness follows the same scale:
&lt;code&gt;0.26 + 0.52 · cell + 0.22 · nearness&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgnzimvidg8ingimdfawz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgnzimvidg8ingimdfawz.webp" alt="The tesseract with the cell nearer in W drawn with thick, bright edges and the far cell with thin, dark ones." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Depth in W as hierarchy: the near cell thick and bright, the far one thin and dim. It's what separates the two cubes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's normalised every frame rather than against a fixed range for a specific&lt;br&gt;
reason: the spread in W changes with the rotation, and with a fixed scale the&lt;br&gt;
hierarchy vanished exactly in the poses where the cube faces you most squarely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four meshes, one shader
&lt;/h2&gt;

&lt;p&gt;The tesseract is drawn with four Three.js meshes that share the same GLSL and&lt;br&gt;
are told apart by a uniform, &lt;code&gt;uLayer&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;Layer&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;How it blends&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Glass edges&lt;/td&gt;
&lt;td&gt;Six-sided tubes, near-black body with a Fresnel rim&lt;/td&gt;
&lt;td&gt;Opaque, writes depth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stroke light&lt;/td&gt;
&lt;td&gt;The point and its trail&lt;/td&gt;
&lt;td&gt;Additive, no depth test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glass sheets&lt;/td&gt;
&lt;td&gt;Six of the 24 faces&lt;/td&gt;
&lt;td&gt;Transparent, double-sided&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Occluder&lt;/td&gt;
&lt;td&gt;A ribbon that only writes depth&lt;/td&gt;
&lt;td&gt;No colour&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few details that took more work than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only six glass faces.&lt;/strong&gt; With all 24 the shape filled in and stopped reading
by its edges. Empty space has to stay the dominant surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-corner normals, not per-face.&lt;/strong&gt; With flat normals the six-sided tubes
look faceted; with each corner's radial normal they shade round without a
single extra triangle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The occluder is a ribbon, not a tube.&lt;/strong&gt; Edges in front had to hide the
trail on the edges behind. A full tube would have pushed the scene from
19,500 to 20,001 vertices and broken a budget a test enforces; a ribbon of
four vertices per edge, turned to face the camera in the vertex shader, costs
128.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rebuilding the geometry every frame
&lt;/h2&gt;

&lt;p&gt;Because the shape changes form — not just position — its vertices are rewritten&lt;br&gt;
every frame: the two tubes (768 vertices each), the occluder ribbon and the&lt;br&gt;
sheets, about 1,700 vertices in all. That sounds expensive and isn't, with three&lt;br&gt;
precautions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every buffer that changes is created with &lt;code&gt;DynamicDrawUsage&lt;/code&gt;, which tells
WebGL it will be rewritten often. UVs and indices never change and are left
alone.&lt;/li&gt;
&lt;li&gt;Temporary vectors are allocated once outside the loop: the loop creates no
objects and the garbage collector has nothing to do.&lt;/li&gt;
&lt;li&gt;Bounding spheres are fixed at a radius of 1.56 instead of being recomputed:
since the shape is already normalised, that radius never changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Without WebGL: the same tesseract in SVG
&lt;/h2&gt;

&lt;p&gt;Not everyone sees the 3D scene. Without WebGL2, on a software GPU or with&lt;br&gt;
effects turned off, the site shows a flat atlas drawn in SVG. The tesseract in&lt;br&gt;
that atlas isn't a separate drawing: it calls the same function with&lt;br&gt;
&lt;code&gt;seconds = 0&lt;/code&gt;, uses the same topology — the same six faces and the same&lt;br&gt;
32-edge circuit — and projects the vertices into a 140 × 140 &lt;code&gt;viewBox&lt;/code&gt;. Each&lt;br&gt;
line's width and opacity come from the same depth in W.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjudubxnidr0g4vk8icz2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjudubxnidr0g4vk8icz2.webp" alt="On the left, the tesseract in WebGL with glass tubes; on the right, the same tesseract drawn in SVG with coloured lines." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same object with and without WebGL. The SVG doesn't animate or run JavaScript, but it shares topology and hierarchy with the scene.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Having both profiles say the same thing was a requirement, not a detail: if the&lt;br&gt;
Experiments destination were an animated cube on one device and something else&lt;br&gt;
on another, the site would be telling two different stories depending on who&lt;br&gt;
was looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pointing at the fourth dimension
&lt;/h2&gt;

&lt;p&gt;In the Observatory, in Study mode, there's an instrument only the tesseract&lt;br&gt;
has: &lt;strong&gt;the probe&lt;/strong&gt;. Move the pointer near an edge and it marks it and tells you&lt;br&gt;
which one it is, which axis it runs along, how deep it is in W and how far away&lt;br&gt;
it sits. It's the only place on the page where you can literally point at the&lt;br&gt;
fourth dimension.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm0bp0h97xjyck0ogob9c.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm0bp0h97xjyck0ogob9c.webp" alt="The Observatory in Study mode with the probe on: a reticle over an edge and the reading “Edge 16 · axis W · W 0.44 · 3.45 r”." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The probe on one of the eight W edges. The reading sits next to the reticle, not in a corner of the screen.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It doesn't use Three.js's &lt;code&gt;Raycaster&lt;/code&gt;. It re-samples the shape at the same&lt;br&gt;
instant, projects the 16 vertices to screen pixels and measures the pointer's&lt;br&gt;
distance to each of the 32 segments. That's 32 point-to-segment checks: cheaper&lt;br&gt;
than casting rays against tubes and exact for the question being asked. In the&lt;br&gt;
first version the reading appeared in the top-left corner, far from the edge,&lt;br&gt;
and came across as a message rather than a measurement. Now it sits next to the&lt;br&gt;
reticle and flips side near the screen edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I left out
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A corridor of frames.&lt;/strong&gt; The first idea was architectural: a corridor of
frames repeating inwards. It became a hypercube of glass edges traced by a
line of light, which is more honest about what a tesseract is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Particles, rays and glows.&lt;/strong&gt; Anything added around it competed with the
shape. They stayed out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A "ghost" trail of previous poses.&lt;/strong&gt; It was proposed as an A/B test with
one condition: if it looked like an After Effects effect, it wouldn't go in.
It didn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The tesseract is in the &lt;a href="https://jonasjavier.dev/en/experiments/observatory/tesseract" rel="noopener noreferrer"&gt;Observatory&lt;/a&gt;:&lt;br&gt;
switch to Study, try the three views and turn the probe on over the edges that&lt;br&gt;
join the two cubes. If you want to know how the rest was made, the black hole&lt;br&gt;
has &lt;a href="https://jonasjavier.dev/en/blog/how-i-built-a-black-hole-in-webgl" rel="noopener noreferrer"&gt;its own post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And if you're thinking about something like this for your product — a 3D&lt;br&gt;
visualisation, an interactive piece or a website people remember — here's&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work and what I do&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasjavier.dev/en/blog/4d-tesseract-in-three-js" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;, next to the &lt;a href="https://jonasjavier.dev/en/experiments/observatory/tesseract" rel="noopener noreferrer"&gt;Observatory&lt;/a&gt;. I'm Jonás Javier Encarnación, a full-stack developer and UX/UI designer in Santo Domingo, Dominican Republic: &lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>webgl</category>
      <category>math</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How I built a black hole in WebGL</title>
      <dc:creator>Jonás Javier Encarnación</dc:creator>
      <pubDate>Fri, 02 Oct 2026 21:17:45 +0000</pubDate>
      <link>https://dev.to/jonasjavier/how-i-built-a-black-hole-in-webgl-2gc1</link>
      <guid>https://dev.to/jonasjavier/how-i-built-a-black-hole-in-webgl-2gc1</guid>
      <description>&lt;p&gt;Gargantua is the centre of this site: the black hole the six sections are&lt;br&gt;
arranged around, inspired by the one in &lt;em&gt;Interstellar&lt;/em&gt;. In the&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/experiments/observatory/gargantua" rel="noopener noreferrer"&gt;Observatory&lt;/a&gt; you can see it full&lt;br&gt;
screen, switch between four views and turn its effects off one by one. This&lt;br&gt;
note explains how it's built and what each part cost me.&lt;/p&gt;
&lt;h2&gt;
  
  
  Don't draw the black hole: follow the light
&lt;/h2&gt;

&lt;p&gt;The first version was a five-layer trick: a disk, a halo and even an Einstein&lt;br&gt;
ring baked onto a plane. You could see the seam between the ring and the disk,&lt;br&gt;
and a seam is exactly what gives away that something is drawn.&lt;/p&gt;

&lt;p&gt;The current version does the opposite. For every pixel, a shader casts a ray&lt;br&gt;
from the camera and lets it fall towards the black hole following the equation&lt;br&gt;
of light around a mass. If the ray falls in, the pixel is black. If it escapes,&lt;br&gt;
it samples the stars in the direction it left. If it crosses the disk, it&lt;br&gt;
picks up its light.&lt;/p&gt;

&lt;p&gt;The photon orbit equation, &lt;code&gt;d²u/dφ² = −u + 1.5·rs·u²&lt;/code&gt;, can be rewritten as a&lt;br&gt;
central force in Cartesian coordinates, &lt;code&gt;a = −1.5·rs·h²·r / |r|⁵&lt;/code&gt;, where&lt;br&gt;
&lt;code&gt;h² = |r × v|²&lt;/code&gt; is computed once per ray. With that, each step is a two-line&lt;br&gt;
Verlet integrator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="kt"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;uRs&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;r2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;nextPos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;n2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nextPos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nextPos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;nextAcc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;uRs&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;nextPos&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kt"&gt;vec3&lt;/span&gt; &lt;span class="n"&gt;nextDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;nextAcc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The beautiful part is what you get for free: the photon ring, the arc of the&lt;br&gt;
disk's far side passing over the shadow, the secondary image beneath it, the&lt;br&gt;
correct shadow size (a radius of √27/2 · rs, about 2.6 times the horizon) and&lt;br&gt;
the Einstein ring of the background stars. None of it is painted: it's what the&lt;br&gt;
light does.&lt;/p&gt;

&lt;p&gt;An honest note: the film's Gargantua spins (it's a Kerr black hole). Here the&lt;br&gt;
spacetime is that of a non-rotating one, Schwarzschild, because tracing Kerr&lt;br&gt;
per pixel in real time was too expensive for a browser. What does spin is the&lt;br&gt;
disk.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkittru78itv6b4gi2evw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkittru78itv6b4gi2evw.webp" alt="Lens view: the large shadow in the centre, the disk crossing it and its doubled image above and below." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Lens view. Rays that circle the hole cross the disk plane again and bring its hidden side above and below the shadow.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Steps that follow the curve
&lt;/h2&gt;

&lt;p&gt;A ray doesn't advance in fixed steps. Each step covers a constant angle around&lt;br&gt;
the hole (&lt;code&gt;dt = scale · r² / h&lt;/code&gt;), capped near the horizon, and relaxes up to&lt;br&gt;
2.4 times as the ray spends its budget. That doubles the turns a ray can make&lt;br&gt;
at no extra cost.&lt;/p&gt;

&lt;p&gt;The previous rule made steps proportional to the distance from the horizon. It&lt;br&gt;
looked reasonable and drew a dark ring that doesn't exist: near the photon&lt;br&gt;
sphere, where light loops several times, rays ran out of steps before getting&lt;br&gt;
out. The budget is 190 steps per pixel on the normal tier and 340 on the deep&lt;br&gt;
one, and a ray stops early if it's certain to fall in (inside 1.5 times the&lt;br&gt;
horizon and heading inwards, about 40 iterations saved) or if nothing ahead of&lt;br&gt;
it can be seen any more.&lt;/p&gt;
&lt;h2&gt;
  
  
  The disk: noise that spins like Kepler
&lt;/h2&gt;

&lt;p&gt;The disk runs from 1.58 to 17 times the horizon radius. Its texture is fractal&lt;br&gt;
noise, evaluated only at the two or three points where each ray crosses the&lt;br&gt;
plane, not at every step, and written in log-radius coordinates that rotate&lt;br&gt;
with the material.&lt;/p&gt;

&lt;p&gt;The rotation is Keplerian: the inner edge spins about 35 times faster than the&lt;br&gt;
outer one. That has a catch: over time, the differential rotation winds the&lt;br&gt;
noise up forever, and within minutes the disk turned into concentric rings&lt;br&gt;
thinner than a pixel. The fix is two copies of the disk, half a cycle apart,&lt;br&gt;
that fade into each other every 20 seconds: the winding never passes a limit&lt;br&gt;
and the handover can't be seen.&lt;/p&gt;
&lt;h2&gt;
  
  
  Doppler: why one side is brighter
&lt;/h2&gt;

&lt;p&gt;The gas on the side coming towards you shines brighter than the side moving&lt;br&gt;
away. It's the relativistic Doppler effect, combined with gravitational&lt;br&gt;
redshift:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;uRs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;uRs&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;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;uRs&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;80&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;gamma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inversesqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1e-3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;beaming&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gamma&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;1e-3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;gravity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;uRs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gravity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;beaming&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uDoppler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;boost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&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;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the final image, the approaching side is a little over twice as bright as&lt;br&gt;
the receding one, and it also shifts towards cream while the other turns&lt;br&gt;
copper. In the Observatory the &lt;strong&gt;Doppler&lt;/strong&gt; control turns it off, and it's the&lt;br&gt;
control that changes the image most of the three.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflnjxo58x12nrf8swe0h.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fflnjxo58x12nrf8swe0h.webp" alt="Gargantua with the Doppler effect: the left side of the disk, moving towards the viewer, is much brighter than the right." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;With Doppler: the approaching side (left) is more than twice as bright as the receding one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuvedg1pr4rt4owdt40bb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuvedg1pr4rt4owdt40bb.webp" alt="The same frame without the Doppler effect: the disk looks almost symmetric." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The same frame without Doppler: both sides of the disk even out.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The shadow the bloom was lighting up
&lt;/h2&gt;

&lt;p&gt;Bloom, the glow that makes bright things feel bright, had a flaw: it spilled&lt;br&gt;
the disk's light into the shadow, and the shadow stopped being black. Darkening&lt;br&gt;
a circle by hand didn't work, because it would erase real lensed arcs: the true&lt;br&gt;
black measures 118 × 73 pixels inside a 142-pixel shadow.&lt;/p&gt;

&lt;p&gt;The fix was to save the image before bloom and bring it back only where the&lt;br&gt;
scene was already dark and inside the shadow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="kt"&gt;vec2&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vUv&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;uCentre&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uRadius&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;vec2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1e-4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;inside&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;smoothstep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uInner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;lum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;vec3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2126&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;7152&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="mo"&gt;0722&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;smoothstep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uDarkGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uDarkGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lum&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;gl_FragColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;vec4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bloomed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inside&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;uAmount&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The brightness at the centre of the shadow dropped from 106.8 to 18.2, and&lt;br&gt;
nothing changed outside it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq1uyg8v21ve8crxtd9m1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq1uyg8v21ve8crxtd9m1.webp" alt="Shadow view: the edge of the shadow up close, pure black against the bright disk." width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Shadow view. Black has to be black: the edge is what tells you there's a hole there and not a lamp.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took out
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A drawn photon ring.&lt;/strong&gt; There was a one-pixel circle at the critical radius.
An exact one-pixel circle is a circle painted on top; now the edge comes from
higher-order images stacking up on their own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A spotlight blob.&lt;/strong&gt; The point where highlights start to compress dropped
from 9.6 to 4.2, and pure-white pixels went from 2177 to 1268.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A lopsided edge.&lt;/strong&gt; The disk looked heavy on one side. It wasn't the shape:
it was the handedness of the spiral, proven by reversing it. The winding went
from 1.15 down to 0.60.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Making it run on any device
&lt;/h2&gt;

&lt;p&gt;All of this is a single draw call: a four-vertex rectangle and one material.&lt;br&gt;
To smooth the edges, the image is accumulated over eight frames with tiny&lt;br&gt;
offsets, and when nothing moves the Observatory stops drawing.&lt;/p&gt;

&lt;p&gt;There are three tiers. The normal one uses 190 steps. The deep one uses 340&lt;br&gt;
and higher resolution, and only switches on with a mouse, a wide screen, eight&lt;br&gt;
cores and eight gigabytes of memory. Without WebGL2, on a software GPU, on 2G&lt;br&gt;
or with little memory, the site serves a flat 2D version with the same text&lt;br&gt;
and the same routes.&lt;/p&gt;

&lt;p&gt;And two things outside the shader:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compiling in parallel.&lt;/strong&gt; With &lt;code&gt;compileAsync&lt;/code&gt; and
&lt;code&gt;KHR_parallel_shader_compile&lt;/code&gt;, the 2.4–2.7 second task that froze the page on
arrival disappeared. In Lighthouse mobile, blocking time went from 7.95 s to
about 2 s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolution that adapts on phones.&lt;/strong&gt; The scene starts at one pixel per point
and steps up to 1.25 and 1.5 only while the phone keeps its own pace; if it
stutters, it steps back down and stays there.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Open the &lt;a href="https://jonasjavier.dev/en/experiments/observatory/gargantua" rel="noopener noreferrer"&gt;Gargantua Observatory&lt;/a&gt;, switch&lt;br&gt;
to &lt;strong&gt;Study&lt;/strong&gt; and go through the four views: Cinematic, Lens, Disk and Shadow.&lt;br&gt;
Then turn &lt;strong&gt;Doppler&lt;/strong&gt;, &lt;strong&gt;Secondary images&lt;/strong&gt; and &lt;strong&gt;Lens&lt;/strong&gt; off one by one and see&lt;br&gt;
which part of the image was physics and which wasn't.&lt;/p&gt;

&lt;p&gt;If you'd like a 3D experience like this for your product or brand, here's&lt;br&gt;
&lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://jonasjavier.dev/en/blog/how-i-built-a-black-hole-in-webgl" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;, next to the &lt;a href="https://jonasjavier.dev/en/experiments/observatory/gargantua" rel="noopener noreferrer"&gt;Observatory&lt;/a&gt;. I'm Jonás Javier Encarnación, a full-stack developer and UX/UI designer in Santo Domingo, Dominican Republic: &lt;a href="https://jonasjavier.dev/en/contact/services" rel="noopener noreferrer"&gt;how I work&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webgl</category>
      <category>threejs</category>
      <category>glsl</category>
      <category>graphics</category>
    </item>
  </channel>
</rss>
