This site started in November 2025 as a weekend thing, a sphere with a texture on it spinning in a black void. It's now 49,000 lines of JavaScript, about 5,200 lines of GLSL across ten shader files, 229 commits, and a viewer that renders every planet, the major moons, the asteroid belt, comets, and a fly mode where you pilot a spacecraft between bodies under something resembling real gravity.
I want to write down the parts that actually mattered, because most of them were not the parts I expected.
The thing nobody warns you about is seams
Planet textures are equirectangular, a rectangular image wrapped around a sphere, and the left edge has to meet the right edge at the 180 degree meridian. Sounds trivial. It is not trivial.
The Solar System Scope textures I started with don't tile cleanly at that meridian. On Uranus you could see a vertical stripe where the edges met, and it wasn't a one pixel artifact either, the entire hundred pixel band at the seam sat at a different tone from the rest of the disc. So I wrote a blend pass that mixes N pixels across the seam, and N had to be tuned per planet because the right answer depends entirely on what's in the image.
const SEAMLESS_TEXTURE_EDGE_FIXES = {
jupiter: { width: 96 },
earth: { width: 512 },
uranus: { width: 192 },
};
Earth gets 512 because its antimeridian runs through open Pacific, so a wide blend is mostly mixing ocean into ocean and nobody can tell. Jupiter gets 96 because it has real banding that a wide blend would smear into mush. Uranus needed 192 because it's the most featureless disc in the system, so you can blend aggressively without destroying anything, and you have to, because that tonal shift is wide.
Then I hit the second half of the problem, which took me embarrassingly long to figure out. I turned on mipmaps for the planet textures to clean up shimmer at distance, and the seam came straight back. Turns out GPU mip generation doesn't wrap, it treats the texture as a flat image with hard edges, so every mip level regenerates a discontinuity at the meridian that my blend pass had carefully removed at level zero. Pre-wrapping the source before generating mips doesn't save you either, you just get a blur stripe instead of a hard line.
So the planet textures run at LinearFilter with no mipmaps at all, and the sharpening happens in the shader instead. There's a luma-only unsharp mask in the Earth fragment shader that ramps up as the camera approaches, which gets you the crispness without touching the sampler.
Earth is a different animal from everything else
Every other body in the scene goes through one function, applyDynamicSurfaceMaterial, which rebuilds a material when its texture arrives or when you focus it. Earth does not. Earth has its own shader with day and night maps, a scrolling cloud layer, an ocean mask, a normal map, a bump map used for actual vertex displacement on the limb, aerial perspective, terminator warmth and glint.
The first line of that shared function is literally a bail out:
if (!group || !bodyData || bodyData.texture === 'earth') return;
That one line ended up shaping a whole performance decision months later, which I'll come back to.
The bump map thing is worth mentioning on its own. Real Earth's tallest peaks are about 8.85 km against a 6,371 km radius, so 0.14% of the radius. At the scene scale that's 0.0035 units of displacement, which is invisible. I amplify it roughly 3x so the limb silhouette actually shows mountains at grazing angles, and that's the kind of decision you make constantly in this thing. Physically correct is often visually nothing.
The viewer took a minute to open and I didn't notice for months
Here's the part I'm least proud of and learned the most from.
Google started sending real traffic this summer, and the top country by a distance is India, which means mobile, which means constrained bandwidth. So I finally sat down and measured what actually happens between clicking the link and being able to move the camera.
The answer was 50 MB. Twenty seven textures, all of them fetched before the loading bar would go away. On a mid-range 4G connection that's north of a minute of staring at a progress bar. There was even a watchdog that gave up after 20 seconds and showed you the scene with grey untextured spheres, which reads as broken rather than loading.
The fix came from actually looking at what the startup shot frames. It picks one body, Earth 60% of the time, Saturn 25%, Jupiter 15%, and puts the camera about three radii out so the planet fills most of the frame. Everything else in the solar system is off screen or a speck. I was blocking the loading screen on a 4.9 MB Ceres texture for an object rendering at maybe two pixels.
So the set got split. There's a boot set, which is whatever the hero body actually needs plus the Sun plus everything small enough that deferring it buys nothing, and then everything else streams in behind the loading screen and swaps its material in as it lands.
before 50.5 MB, always
after 27.3 MB Earth hero, 9.3 MB Saturn, 11.1 MB Jupiter
Deep links from the content pages get the small path too, which matters because that's where most of the search traffic enters.
Remember that bodyData.texture === 'earth' bail out? It means Earth's day map can never be deferred, because if it's missing when the planet is built, Earth gets the solid color fallback and there is no code path that will ever fix it. But every other Earth texture is a plain uniform on the shader material, so those can be bound late with a direct assignment and no rebuild:
u.cloudsTexture.value = texture;
u.cloudTexelSize.value = getTextureTexelSize(texture);
Cloud opacity ramps from zero over 900 ms when it arrives, so a late cloud layer reads as a reveal rather than a pop.
SSIM lied to me for about an hour
While I was in there I tried to make the textures smaller without any visible change. The obvious move is re-encoding to WebP and measuring quality with SSIM against the source, keeping anything that scores above some threshold.
Almost everything failed. Jupiter came back at 0.949 for a 76% size cut, which made no sense for a smooth banded disc.
The measurement was wrong. I was comparing a decoded PPM against the compressed WebP file directly, and ffmpeg has to convert between those, so a chunk of what I was calling "quality loss" was chroma subsampling round trips in my own measurement pipeline. Decode both sides to PPM first and compare like for like, and the numbers move by one to two hundredths, which is exactly the range I was making decisions in.
Then there was a dumber one. Every mozjpeg candidate was silently failing because cjpeg on my machine is built without libpng, so it rejected every PNG I fed it and my try/catch swallowed the error. The whole encoding pass reported "kept as-is" for every file, and I read that as a fidelity result instead of a missing codec.
What I eventually shipped was the boring answer. Every source was baseline JPEG, and jpegtran can rewrite baseline as progressive with optimized Huffman tables without touching a single DCT coefficient, so the decoded pixels come back bit for bit identical. I verified that by SHA-256 hashing the decoded output rather than trusting it. Seven percent across the set, free, and progressive means the image resolves in passes on a slow line instead of top to bottom.
There's also a nice bit of shader archaeology here. The cloud texture is 8192x4096 and 10 MB, and I went looking for a way to shrink it. Turns out every single sample in the Earth shader reads .r and nothing else, so the green and blue channels are dead weight. All six of the data maps are perfectly neutral grayscale stored as RGB. Sadly jpegtran -grayscale only recovered 3%, because JPEG's chroma subsampling had already made those flat channels nearly free. Good instinct, no prize.
11 MB of the same PNG, eleven times
This one was pure luck. I dumped the composition of every GLB in the project, and iss-stationary-nasa.glb came back at 42.4 MB with 26 embedded PNGs totalling 30.8 MB.
Then I hashed each embedded image and 11.65 MB of it was byte for byte duplicates. The same textures embedded over and over in one file. glTF lets several images point at a single bufferView, so all of that was recoverable by rewriting the file with each blob stored once and remapping the references.
42.4 MB down to 30.8 MB, and I do mean lossless, the surviving bytes are the same bytes. I made the script verify every image and every accessor byte range against the input before writing anything, because a silently corrupted mesh is a much worse outcome than a large file.
Every other model in the project came back clean, which makes sense, they'd already been through gltf-transform with meshopt and webp. The ISS was the one raw NASA asset I'd never processed.
Your browser can't fetch what it hasn't found yet
Last one, and it's my favorite because it's so cheap.
The viewer HTML carries about 190 KB of inline CSS in its head, and Vite emits the module script tag after all of it, at byte 195,890. So the preload scanner cannot see the bundle until the entire head has arrived and parsed, and no texture can be requested until that bundle has downloaded and executed. Three things happening strictly one after another that could all happen at once.
Three lines of <link> at the top of the head fixes it:
before bundle starts 280ms, first texture 302ms
after bundle + sun + earth all start 29ms
The one thing to get right is crossorigin on the image preloads. Three.js requests textures with crossOrigin set to anonymous, and a preload whose credentials mode doesn't match gets thrown away and refetched, which would have doubled the download instead of removing the wait. Worth checking your network panel for exactly one request per URL rather than assuming.
What I'd tell you if you're building something like this
Measure the thing your users actually experience, not the thing you find interesting. I spent months tuning a cloud shader's terminator warmth on a page that took a minute to open on the connection most of my visitors were using. The shader work was more fun and the loading work was worth more.
Be suspicious of your measurement tools before you're suspicious of your data. Two separate times in one afternoon I nearly made a bad call because my instrument was broken rather than the thing I was measuring.
And dump the actual bytes occasionally. Not the abstractions, the bytes. Hash the images inside your binaries, count bytes per pixel across your texture set, look at where the script tag sits in your document. There was a 17x spread in encoding efficiency across my textures, Earth's 8K surface shipping at 0.136 bytes per pixel and looking superb next to a dwarf planet nobody looks at burning 0.637. You don't find that by reading your own code, you find it by weighing things.
The site's at 3dsolarsystem.online and the viewer is right here, free, no signup, no accounts, and it now opens in about a second.