Back to News & Insights
SEO October 2, 2026 · 8 min read

A 3D portfolio Google can read and a phone can run

A site with a black hole ray-traced in real time is usually invisible to search engines and heavy on a phone. The rules, effect levels and measurements that keep this one from being either.

A 3D portfolio Google can read and a phone can run

3D portfolios have a bad reputation for two reasons, and both are fair. To a search engine they're usually an empty : all the text lives inside the scene and there's nothing to index. And on a phone they're usually a black screen that takes a while to start, heats up and drains the battery.

This site has a black hole traced pixel by pixel in a shader, with six bodies around it. This post covers the rules and pieces that keep it readable for Google and usable on a phone, with the measured numbers — and with what still isn't solved.

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

In practice that comes down to four things: The is aria-hidden, fixed behind the page (position: fixed; z-index: -1; pointer-events: none) and contains not one word. On the home page, the name and role are in a real , and the six destinations are six real links with a minimum 44 px touch target. The clickable "bodies" over the scene are copies hidden from screen readers (aria-hidden, out of the tab order): the link that counts is the text one. 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. The canvas must not be the largest contentful paint (LCP): by design, what paints first is light HTML and CSS.

The most direct test is to turn JavaScript off and load the home page. This is what you get:

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.

It isn't an "enable JavaScript" screen: it's the site. The links work, the text is there and the composition is the same. It's also, almost pixel for pixel, what a visitor sees without WebGL2 or with effects turned off.

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

A few details that matter: A software GPU is detected, not guessed. A throwaway canvas reads the renderer name through WEBGLdebugrendererinfo and checks it against swiftshader, llvmpipe, softpipe and so on. It then releases that context with WEBGLlosecontext and caches the answer for the session. A missing signal doesn't count against you. Safari exposes neither deviceMemory nor the connection type; treating that absence as "weak device" would leave every iPhone without the scene. The visitor decides. 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, deep (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.

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

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

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.

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

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

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

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

Measured with mobile Lighthouse on a machine with a real integrated GPU: Total Blocking Time went from 7.95 s to about 2.0 s, Time to Interactive from 12.0 to 7.5 s and Speed Index from 7.1 to 4.1 s. Firefox lacks the extension and keeps the old path.

Half of the site's performance gains had nothing to do with 3D. A page-by-page Lighthouse review found three classic problems: A lying sizes. 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 sizes set to what's actually painted (120 and 180 px), that stopped. Phone crops. 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 fetchPriority: "high" and its own media, so the phone doesn't download the desktop version. Steps and quality. 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.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation