gas-giant

The cloud tops of a gas giant, as one shader function. Banded, sheared by winds that alternate direction from belt to belt, with a long-lived storm. It colours a point on a sphere, so the same code draws a giant hanging in a moon's sky (<tosi-b3d-moon gas="jupiter">) and one you orbit.

Demo

A giant as a body you can fly round: <tosi-b3d-planet gas="saturn">. Drag to orbit it. The sun is where the scene's sun is, so swing round to the night side. The bands' three colours and the ring are yours to set: pick another planet and note how thin and faint every ring but Saturn's is.

import { b3d, b3dPlanet, b3dSun, b3dSkybox, GAS_GIANTS, select3d, slider3d, label3d, colorPicker3d } from 'tosijs-3d'
import { tosi } from 'tosijs'

const start = GAS_GIANTS.saturn
const { orbit } = tosi({ orbit: {
  kind: 'saturn', seed: 7, spin: 0.03,
  bands: start.bands, turbulence: start.turbulence, storm: start.storm,
  zones: start.colors[0], belts: start.colors[1], accent: start.colors[2],
  rings: 1, ringInner: start.rings.inner, ringOuter: start.rings.outer, ringOpacity: start.rings.opacity, ringColor: start.rings.color,
} })
const planet = b3dPlanet({ gas: orbit.kind, seed: orbit.seed, rotationSpeed: orbit.spin, radius: 50 })

// Everything that changes the planet's look rebuilds it (a sphere: cheap).
const rebuild = () => {
  Object.assign(planet, {
    bands: orbit.bands.value, turbulence: orbit.turbulence.value, storm: orbit.storm.value,
    bandColors: [orbit.zones.value, orbit.belts.value, orbit.accent.value].join(','),
    rings: orbit.rings.value, ringInner: orbit.ringInner.value, ringOuter: orbit.ringOuter.value,
    ringOpacity: orbit.ringOpacity.value, ringColor: orbit.ringColor.value,
  })
  planet.regenerate()
}
// Choosing a planet sets every dial to that planet's own numbers.
orbit.kind.observe(() => {
  const look = GAS_GIANTS[orbit.kind.value]
  Object.assign(orbit, {
    bands: look.bands, turbulence: look.turbulence, storm: look.storm,
    zones: look.colors[0], belts: look.colors[1], accent: look.colors[2],
    ringInner: look.rings.inner, ringOuter: look.rings.outer, ringOpacity: look.rings.opacity, ringColor: look.rings.color,
  })
})
for (const key of Object.keys(orbit.valueOf())) if (key !== 'spin') orbit[key].observe(rebuild)

const planetColours = Object.values(GAS_GIANTS).flatMap((look) => look.colors)
preview.append(
  b3d(
    {
      scenePanelOpen: true,
      scenePanel: () => [
        label3d({ text: 'From space' }),
        select3d({ label: 'planet', value: orbit.kind, options: Object.keys(GAS_GIANTS) }),
        slider3d({ label: 'bands', value: orbit.bands, min: 1, max: 30, step: 1 }),
        slider3d({ label: 'turbulence', value: orbit.turbulence, min: 0, max: 1.5, step: 0.05 }),
        slider3d({ label: 'storm', value: orbit.storm, min: 0, max: 2, step: 0.05 }),
        colorPicker3d({ label: 'zones', value: orbit.zones, alpha: false, presets: planetColours }),
        colorPicker3d({ label: 'belts', value: orbit.belts, alpha: false, presets: planetColours }),
        colorPicker3d({ label: 'accent', value: orbit.accent, alpha: false, presets: planetColours }),
        label3d({ text: 'Rings' }),
        slider3d({ label: 'amount', value: orbit.rings, min: 0, max: 1, step: 0.05 }),
        slider3d({ label: 'inner edge (radii)', value: orbit.ringInner, min: 1.05, max: 3, step: 0.05 }),
        slider3d({ label: 'outer edge (radii)', value: orbit.ringOuter, min: 1.1, max: 4, step: 0.05 }),
        slider3d({ label: 'opacity', value: orbit.ringOpacity, min: 0, max: 1, step: 0.01 }),
        colorPicker3d({ label: 'ring colour', value: orbit.ringColor, alpha: false }),
        label3d({ text: 'Motion' }),
        slider3d({ label: 'seed', value: orbit.seed, min: 1, max: 96, step: 1 }),
        slider3d({ label: 'rotation', value: orbit.spin, min: 0, max: 0.3, step: 0.01 }),
      ],
      sceneCreated(el, BABYLON) {
        const camera = new BABYLON.ArcRotateCamera('orbit', -Math.PI / 3, Math.PI / 2.4, 220, BABYLON.Vector3.Zero(), el.scene)
        camera.lowerRadiusLimit = 70
        camera.upperRadiusLimit = 700
        camera.maxZ = 3000
        camera.attachControl(el.querySelector('canvas'), true)
        el.setActiveCamera(camera)
      },
    },
    b3dSun(),
    b3dSkybox({ timeOfDay: 15, realtimeScale: 0, atmosphere: 0, starfieldCube: '/sky/nebula', starfieldData: '/sky/stars' }),
    planet,
  )
)
rebuild()
.preview { height: 100%; }

In a moon's sky

The same planet hanging over the horizon of one of its moons, <tosi-b3d-moon gas="jupiter">. Pick a planet, then pull it about: the dials start at the preset's own values.

import { b3d, b3dSun, b3dSkybox, b3dMoon, b3dGround, GAS_GIANTS, slider3d, select3d, label3d } from 'tosijs-3d'
import { tosi } from 'tosijs'

const { gg } = tosi({ gg: { kind: 'jupiter', size: 28, time: 16, tilt: 10, ...GAS_GIANTS.jupiter } })
const planet = b3dMoon({
  frame: 'ground', azimuth: 22, elevation: 22,
  gas: gg.kind, size: gg.size, axisTilt: gg.tilt,
  bands: gg.bands, turbulence: gg.turbulence, storm: gg.storm,
})
// Choosing a planet resets the dials to that planet's numbers.
gg.kind.observe(() => {
  const look = GAS_GIANTS[gg.kind.value]
  gg.bands.value = look.bands
  gg.turbulence.value = look.turbulence
  gg.storm.value = look.storm
})

preview.append(
  b3d(
    {
      scenePanelOpen: true,
      scenePanel: () => [
        label3d({ text: 'Gas giant' }),
        select3d({ label: 'planet', value: gg.kind, options: Object.keys(GAS_GIANTS) }),
        slider3d({ label: 'bands', value: gg.bands, min: 1, max: 30, step: 1 }),
        slider3d({ label: 'turbulence', value: gg.turbulence, min: 0, max: 1.5, step: 0.05 }),
        slider3d({ label: 'storm', value: gg.storm, min: 0, max: 2, step: 0.05 }),
        slider3d({ label: 'tilt', value: gg.tilt, min: -45, max: 45, step: 1 }),
        slider3d({ label: 'size (degrees)', value: gg.size, min: 2, max: 80, step: 1 }),
        slider3d({ label: 'time of day (its phase)', value: gg.time, min: 0, max: 24, step: 0.25 }),
      ],
      sceneCreated(el, BABYLON) {
        const cam = new BABYLON.FreeCamera('c', new BABYLON.Vector3(0, 2, 0), el.scene)
        cam.setTarget(new BABYLON.Vector3(0, 6, 10))
        cam.attachControl(el.querySelector('canvas'), true)
        el.setActiveCamera(cam)
      },
    },
    b3dSun({}),
    b3dSkybox(
      { timeOfDay: gg.time, realtimeScale: 0, atmosphere: 0, starfieldCube: '/sky/nebula', starfieldData: '/sky/stars', starfieldTilt: '12,25,58' },
      planet,
    ),
    b3dGround({ width: 80, height: 80, color: '#6a6250' }),
  )
)
.preview { height: 100%; }

How it works

It is the cloud deck's idea turned on its side: a noise field advected by wind. There the wind blows across a plane; here each latitude has its own wind, which is what draws the bands out into streaks.

What the numbers mean

bands How many light and dark stripes from pole to pole, counting both. Jupiter is about 14, Neptune 6. Those near the equator are the broadest
turbulence How far the edges of the bands are torn, 0…1. 0 is ruled lines; 1 is Jupiter
storm Size of the great storm, 0…1 of a band's width. 0 is none
colors Three: the bright zones, the dark belts, and the accent (the storm, the poles)
seed A different planet with the same look
rings The preset's ring: inner and outer edge in planet radii, opacity and color. Saturn's is broad and bright; the others are thin and nearly invisible, which is true to life

Presets

GAS_GIANTS has jupiter, saturn, uranus and neptune. resolveGasGiant takes a preset name and any overrides and returns the numbers the shader wants.

The shader

GAS_GIANT_GLSL defines

vec3 b3dGasGiant(vec3 n, vec4 k, vec3 c0, vec3 c1, vec3 c2, float t)

n is the unit normal in the planet's own frame (Y is the spin axis), k is (bands, turbulence, storm, seed), the colours are linear RGB and t is seconds. It returns the unlit colour. No textures and no other functions are needed: it carries its own noise.

vec3 b3dGasGiantLit(vec3 albedo, float nDotL, float nDotV, vec3 haze)

lights it as a ball of gas: brightness falls away from the point under the sun and again toward the limb, and a thin rim of haze shows on the day side. A flat disc with a hard terminator reads as a coin; this is what makes it a sphere. gas-giant-material wraps both for a mesh.