DEV Community

Cover image for I ray-traced the Moon in a single shader, then put its center up for auction
Krishna Garlapati
Krishna Garlapati

Posted on

I ray-traced the Moon in a single shader, then put its center up for auction

We're live on Product Hunt today: come say hi

Gliding over the craters of buythemoon.lol

Right now there's a King of the Moon, and anyone can take the crown.

buythemoon.lol is a real-time 3D moon you can spin and zoom right down to individual craters. Anyone can put their logo on it: the bigger the bid, the bigger the logo, and the top bid becomes King of the Moon and takes the center.

TL;DR

  • No mesh and no three.js: one fragment shader ray-traces the moon, pixel by pixel.
  • Real NASA colour and elevation data, with crater shadows baked offline so it runs on phones.
  • Logos keep their real shape (signed distance fields) and are packed onto a sphere without stretching or overlapping.
  • The whole thing runs on Cloudflare Pages + D1.

The Moon has no polygons

There's no sphere geometry at all. The canvas draws a single full-screen triangle, and a fragment shader casts a ray from the camera through every pixel and intersects it with a unit sphere:

void main(){
  vec2 ndc = (gl_FragCoord.xy - 0.5 * uRes) / (0.5 * uRes.y);
  vec3 dirV = normalize(vec3(ndc * uTanHalf, -1.0));
  vec3 ro = uViewToMoon * vec3(0.0, 0.0, uDist);     // camera position, in moon space
  vec3 rd = normalize(uViewToMoon * dirV);            // this pixel's ray
  float b = dot(ro, rd), c = dot(ro, ro) - 1.0, disc = b*b - c;
  ...
  float t = -b - sqrt(max(disc, 0.0));                // nearest hit on the sphere
  vec3 p = normalize(ro + rd * t);                    // the point on the moon this pixel sees
  vec2 uv = equirect(p);
  vec3 albedo = toLin(sampleEq(uAlbedo, uv).rgb);
  float light = pow(sampleEq(uLight, uv).r, 2.2);
Enter fullscreen mode Exit fullscreen mode

Because the sphere is analytic, it's perfectly round at any zoom, and the "geometry" costs nothing. Rotation is a 3×3 matrix (built from a quaternion trackball) that turns the camera instead of the moon. Even the anti-aliased edge of the moon comes from the ray's distance to the limb, smoothed with fwidth.

The trick that makes it run on a phone

The textures come from NASA's Scientific Visualization Studio CGI Moon Kit: a colour map from the Lunar Reconnaissance Orbiter Camera and an elevation map from the LOLA laser altimeter.

Lighting the surface in real time on every phone would be expensive, so I bake it offline. A Python script:

  1. turns the elevation map into surface normals for slope shading;
  2. ray-marches toward the sun from every texel near the terminator to find which crater rims cast shadows on which crater floors;
  3. adds a dim, soft-relief night side so the dark half isn't pitch black.

The browser just samples the result. It's cheap to render, and it still looks like the Moon, with long shadows along the terminator. Earth (NASA Blue Marble) and the Sun sit in the background in the right directions, so they swing into view when you spin the moon around.

Putting a logo on a ball without stretching it

Every logo goes into a texture atlas, along with a signed distance field of its outline. That's what lets a logo with a transparent background keep its real shape on the moon instead of becoming a rectangle. The SDF also gives a crisp anti-aliased edge and a cheap "glow" when you hover a logo.

To avoid stretching, each logo gets its own local frame on the sphere and is mapped with an azimuthal equidistant projection around its centre. Distances from the logo's centre are preserved, so a logo near the pole looks the same as one at the equator.

The hard part nobody warns you about: packing shapes on a sphere

You can't tile a sphere with arbitrary shapes. So:

  • each logo is sized from its share of the logo space (bigger bid, bigger share; the King gets 3.5× the share of #2);
  • logos start roughly in place, then a relaxation step checks for overlaps on a few thousand points spread evenly over the sphere (a Fibonacci lattice) and pushes overlapping logos apart;
  • smaller logos give way to bigger ones, and the King never moves:
// the smaller picture gives way: each moves in proportion to the other's size; the King never moves
const fa = list[a].id === fixedId ? 0
         : (list[b].id === fixedId ? 1 : list[b].s / (list[a].s + list[b].s)), fb = 1 - fa;
for (let q = 0; q < 3; q++){ push[a][q] += d[q] * m * fa * 2; push[b][q] -= d[q] * m * fb * 2; }
Enter fullscreen mode Exit fullscreen mode

If a small logo stays stuck, it jumps to the emptiest spot on the moon, the point farthest from every logo's edge:

function emptiestSpot(list, skip){
  let best = GRID[0], bs = -Infinity;
  for (let t = 0; t < 500; t++){
    const p = GRID[Math.floor(rand() * NG)];
    let sc = Infinity;
    for (const o of list) if (o !== skip) sc = Math.min(sc, Math.acos(clamp(dot(p, o.c), -1, 1)) - o.s * 1.2);
    if (sc > bs){ bs = sc; best = p; }
  }
  return best.slice();
}
Enter fullscreen mode Exit fullscreen mode

Only if everything still collides does every logo shrink, all by the same factor, so sizes stay in proportion. One subtle detail: all the randomness comes from a seeded PRNG (mulberry32), seeded from the list of owners. Every visitor computes exactly the same layout, so your logo is in the same place for everyone.

The game: fight for the center

  • Bids are whole dollars, from $10.
  • Your bid sets your rank and your size.
  • To take the Crown, you have to beat the King by 5% or $5, whichever is more.
  • Get outbid and you shrink, but you never disappear.

The boring stack that shipped it

  • Cloudflare Pages for the static site, Pages Functions for checkout, webhooks and badges, D1 (SQLite) as the database.
  • Payments through a merchant of record, so tax and receipts are handled.
  • Paste your website at checkout and a function hunts for your best logo (SVG first, then the largest icon it can find) and your name and tagline, so most people never upload anything.
  • Every buyer gets a personal badge (/api/badge/<id>.svg) that links back to their spot on the moon.

What broke, and what I'd do differently

The first version passes logo data to the shader in uniform arrays, which caps how many logos it can draw at once, and every pixel checks every logo. The next version moves logo data into a texture and precomputes an "ownership map" of the sphere, so each pixel only checks the one logo it can possibly be inside. That's the road to thousands of logos.

And I'd start launch prep earlier. Payment-provider review, legal pages and a demo video took longer than the shader.


If you want to see it (or take the Crown 👑), it's at buythemoon.lol.

Question for you: if you could put any logo at the center of the moon, yours, your company's, or something purely for fun, what would it be? 👇

I'm @KrishSGarlapati on X, and I'll be answering every question about the rendering in the comments.

Top comments (0)