Adrian: A Lava Planet with Curl Noise

Adrian is a small demo I made inside Detramotor to play with curl noise. Named after the planet Adrian from Project Hail Mary, a greenish gas giant. The original plan was to recreate that swirling atmospheric look, but I couldn't quite get there, so it ended up as more of a lava planet. This was my first crack at curl noise, so the implementation is more "looks about right" than mathematically rigorous, but I'm happy with it as a first version. I plan to do a proper gas giant version in a future post.

What is curl noise?

The idea behind curl noise is surprisingly simple. You start with a regular noise function (Perlin, simplex, value noise, whatever). That noise gives you a scalar field: for every point in space, you get a number. Now take the gradient of that field. The gradient points in the direction of steepest increase, which is useful but not what we want here. If you move particles along the gradient, they'd all converge toward the peaks or flow away from the valleys. Not very interesting.

The trick is to rotate that gradient 90 degrees. In 2D, that means if your gradient is (dx, dy), you use (dy, -dx) instead. This gives you a vector field that is divergence-free, which is a fancy way of saying the flow never converges or diverges. Particles following this field will swirl around in smooth streams without piling up or creating voids. It looks like fluid.

In math terms, for a 2D scalar noise field N(x,y)N(x, y), the curl is:

C(x,y)=(Ny,  Nx)\vec{C}(x, y) = \left(\frac{\partial N}{\partial y},\; -\frac{\partial N}{\partial x}\right)

That's literally it. Take the noise, compute the partial derivatives, swap and negate one. You get a vector field that naturally produces swirling, organic motion.

Try it

Here's a live 2D version of the curl noise advection. The UV grid starts as a regular grid and gets distorted over time by the curl field, then those distorted UVs are used to sample a lava-like color. Switch to "Flow Field" mode to see the raw curl vectors, or "Both" to see the flow overlaid on the lava. Crank up iterations to see stronger distortion per frame.

2D curl noise flow. The noise gradient is rotated 90 degrees to produce a divergence-free vector field. UV coordinates are advected through this field, then used to sample a procedural lava color. "Iterations" controls how many advection steps each texel takes per frame (matching ubo.iterations in the shader).

How the shader works

The implementation has two passes: a curl advection pass (fullscreen quad) and the actual planet rendering pass.

Pass 1: Curl advection

This runs as a fullscreen quad. Each pixel represents a UV coordinate that gets pushed around by the curl field. The output is a texture where each texel stores the new advected UV position.

void main() {
  vec2 position = texture(uParticleSrc, vUV).xy;

  for(uint i = 0; i < ubo.iterations; i++) {
    vec2 direction = texture(uNoise, position).xy;
    direction = vec2(direction.y, -direction.x);  // the curl rotation

    position += direction * ubo.dt * 0.1;
  }

  fUV = position;
}

The key line is vec2(direction.y, -direction.x). That's the 90 degree rotation that turns a noise gradient into a curl field. The shader samples the noise texture at the current position, rotates the result, and steps forward. It does this ubo.iterations times per frame, so you can control how much distortion accumulates each frame.

The uParticleSrc texture is the output from the previous frame (or the cleared identity UV from curl_clear), so the advection is cumulative. Over time, the UVs get more and more warped, creating those flowing lava streams.

Pass 2: Planet rendering

The second pass renders the actual sphere mesh. It takes the advected UV texture and uses it to look up colors from an albedo texture. This is where it gets aggressive with the color math:

vec3 getColor(vec2 uv) {
  vec3 color = texture(uAlbedo, texture(uPosition, vec2(1.0) - uv).xy).xyz;
  color = pow(color, vec3(8.0)) * 3.0;
  color += pow(texture(uAlbedo, uv).xyz * 0.4, vec3(5.0)) * 100.0;
  color = color * color;
  return color;
}

There's a lot happening in those four lines so let me break it down:

  1. First it samples the albedo using the advected UVs (not the original mesh UVs). The texture(uPosition, ...) call is the lookup into the curl-advected UV texture. The vec2(1.0) - uv flip adds a mirror effect.

  2. Then it raises the color to the power of 8 and multiplies by 3. This is an extreme contrast curve that crushes everything dark and makes bright spots pop. Think of it as a "lava glow" filter.

  3. It adds a second sample of the albedo at the original UVs, raised to the power of 5 and multiplied by 100. This creates a secondary hot layer that bleeds through the flow.

  4. Finally it squares the whole thing (color * color). At this point only the very brightest areas survive. Everything else is basically black.

The result is a surface where most of it is dark rock with bright cracks of molten lava flowing through it. The extreme power curves are doing all the work here. Without them you'd just see a blurry swirl of colors.

The fragment shader then tonemaps the result so the HDR values don't clip:

void main() {
  // ... same color computation ...
  fColor = vec4(tonemap(color, 10.0, 0), 1.0);
}

Voxel GI integration

One cool detail is that the vertex shader also writes into the engine's voxel GI system. It stores the lava emission color into a 3D voxel texture so the glowing lava actually illuminates nearby objects in the scene:

vec3 writeColor(vec3 color) {
  vec3 voxelPoint = world2volume(vPosition);
  ivec3 voxelIdx = ivec3(floor(voxelPoint));
  imageStore(voxelEmission, voxelIdx, vec4(color, 1.0));
  // ...
}

This is called from the vertex shader (not the fragment shader), which is a bit unusual. It means the voxelization happens at vertex granularity rather than per-pixel, which is coarser but cheaper. The lava planet basically becomes a glowing emissive light source in the scene for free.

The clear pass

There's also a curl_clear pass that just writes out the identity UV mapping:

void main() {
  fUV = vUV;
}

This resets the advection. Without it the UVs would keep accumulating distortion forever and eventually turn into noise soup. In practice you'd either reset periodically or mix the advected UVs back toward the original over time to keep things stable.

Why "Adrian"?

The name comes from the planet Adrian in the movie Project Hail Mary. Adrian is this greenish gas planet, and the original idea was to recreate that look with swirling atmospheric currents. I couldn't quite get there though, so what came out instead is more of a lava planet. Still looks cool, just not what I was aiming for.

Adrian from Project Hail Mary

The actual Adrian from Project Hail Mary. Greenish gas giant. Mine turned out a bit more... on fire.

This was my first attempt at curl noise, so the implementation is more of a rough sketch than a polished system. The curl computation isn't technically correct (it rotates a sampled vector instead of computing derivatives of a scalar field), and the color math went in a completely different direction from what Adrian actually looks like. I plan to revisit this in a future post with a proper implementation that actually looks like a gas giant instead of a ball of magma.

Wrapping up

Curl noise is one of those techniques that gives you a lot of visual bang for very little code. The entire flow simulation is essentially one line (the 90 degree rotation), the rest is just plugging it into a render pipeline and making the colors look hot. If you want to try it yourself, start with any 2D noise, compute the gradient numerically (sample at x+eps and x-eps, divide by 2*eps), rotate 90 degrees, and advect some UVs through it. You'll have flowing lava in about 20 lines of shader code.