src/textures/procedural.js
// ————————————————————————————————————————————————
// Procedural planet textures — Canvas2D → THREE.CanvasTexture.
//
// Everything here is deterministic (seeded mulberry32 RNG) and runs once at
// startup. No network requests, no external images. Works fully offline.
//
// Shared by solar-system.html and scale.html so both views render identical
// planet surfaces.
// ————————————————————————————————————————————————
import * as THREE from 'three';
export function makeCanvas(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
// Deterministic PRNG so the same seed produces the same texture every time.
export function mulberry32(seed) {
return function () {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Scatter noisy speckles across the canvas for surface grain.
function addSpeckles(ctx, w, h, count, rng, colorFn, sizeMin = 0.5, sizeMax = 2) {
for (let i = 0; i < count; i++) {
const x = rng() * w,
y = rng() * h;
const r = sizeMin + rng() * (sizeMax - sizeMin);
ctx.fillStyle = colorFn(rng);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
}
// ————————————————————————————————————————————————
// Sun — radial plasma blobs over a warm gradient.
// ————————————————————————————————————————————————
export function sunTexture() {
const w = 1024,
h = 512;
const c = makeCanvas(w, h);
const ctx = c.getContext('2d');
const rng = mulberry32(1);
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, '#ffdc6a');
g.addColorStop(0.5, '#ffb833');
g.addColorStop(1, '#ff8a1a');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 400; i++) {
const x = rng() * w,
y = rng() * h;
const r = 20 + rng() * 60;
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
const hot = rng() > 0.5;
grd.addColorStop(0, hot ? 'rgba(255,240,180,0.55)' : 'rgba(220,80,30,0.45)');
grd.addColorStop(1, 'rgba(255,180,60,0)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
addSpeckles(
ctx,
w,
h,
800,
rng,
() => `rgba(255,${(220 + rng() * 35) | 0},${(100 + rng() * 80) | 0},0.5)`,
0.5,
1.8
);
return new THREE.CanvasTexture(c);
}
// ————————————————————————————————————————————————
// Inner rocky planets
// ————————————————————————————————————————————————
export function mercuryTexture() {
const w = 1024,
h = 512,
c = makeCanvas(w, h),
ctx = c.getContext('2d');
const rng = mulberry32(2);
ctx.fillStyle = '#8a8276';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 60; i++) {
ctx.fillStyle = `rgba(${(90 + rng() * 40) | 0},${(85 + rng() * 30) | 0},${(75 + rng() * 25) | 0},0.4)`;
const y = rng() * h,
hh = 4 + rng() * 12;
ctx.fillRect(0, y, w, hh);
}
for (let i = 0; i < 500; i++) {
const x = rng() * w,
y = rng() * h,
r = 2 + rng() * 10;
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'rgba(50,45,40,0.55)');
grd.addColorStop(0.7, 'rgba(180,170,155,0.25)');
grd.addColorStop(1, 'rgba(180,170,155,0)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
addSpeckles(
ctx,
w,
h,
6000,
rng,
() => `rgba(${(60 + rng() * 60) | 0},${(60 + rng() * 60) | 0},${(55 + rng() * 50) | 0},0.3)`,
0.4,
1.2
);
return new THREE.CanvasTexture(c);
}
export function venusTexture() {
const w = 1024,
h = 512,
c = makeCanvas(w, h),
ctx = c.getContext('2d');
const rng = mulberry32(3);
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, '#d4a055');
g.addColorStop(0.5, '#e6bc74');
g.addColorStop(1, '#b88540');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 30; i++) {
ctx.fillStyle = `rgba(${(220 + rng() * 30) | 0},${(180 + rng() * 40) | 0},${(100 + rng() * 50) | 0},0.25)`;
const y = rng() * h,
hh = 8 + rng() * 40;
ctx.beginPath();
ctx.moveTo(0, y);
for (let x = 0; x <= w; x += 16) {
ctx.lineTo(x, y + Math.sin(x * 0.01 + i) * hh * 0.2);
}
ctx.lineTo(w, y + hh);
ctx.lineTo(0, y + hh);
ctx.fill();
}
addSpeckles(
ctx,
w,
h,
4000,
rng,
() =>
`rgba(${(240 - rng() * 40) | 0},${(200 - rng() * 40) | 0},${(120 - rng() * 30) | 0},0.18)`,
0.5,
1.5
);
return new THREE.CanvasTexture(c);
}
export function earthTexture() {
const w = 1024,
h = 512,
c = makeCanvas(w, h),
ctx = c.getContext('2d');
const rng = mulberry32(4);
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, '#1e3a6b');
g.addColorStop(0.5, '#2a5a9a');
g.addColorStop(1, '#1e3a6b');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 40; i++) {
const cx = rng() * w,
cy = rng() * h;
const base = 15 + rng() * 55;
ctx.fillStyle = ['#3a6b2e', '#4a7d35', '#6b8a40', '#8a6a3a'][i % 4];
ctx.beginPath();
const blobs = (8 + rng() * 6) | 0;
for (let j = 0; j < blobs; j++) {
const a = (j / blobs) * Math.PI * 2;
const rr = base * (0.5 + rng() * 0.9);
const x = cx + Math.cos(a) * rr,
y = cy + Math.sin(a) * rr * 0.5;
if (j === 0) ctx.moveTo(x, y);
else
ctx.quadraticCurveTo(
cx + Math.cos(a - 0.3) * rr * 1.2,
cy + Math.sin(a - 0.3) * rr * 0.7,
x,
y
);
}
ctx.closePath();
ctx.fill();
}
ctx.fillStyle = 'rgba(240,245,250,0.85)';
ctx.fillRect(0, 0, w, 22);
ctx.fillRect(0, h - 22, w, 22);
for (let i = 0; i < 120; i++) {
ctx.fillStyle = `rgba(255,255,255,${0.08 + rng() * 0.18})`;
const x = rng() * w,
y = rng() * h,
rr = 10 + rng() * 40;
ctx.beginPath();
ctx.ellipse(x, y, rr, rr * 0.35, rng() * Math.PI, 0, Math.PI * 2);
ctx.fill();
}
return new THREE.CanvasTexture(c);
}
export function moonTexture() {
const w = 1024,
h = 512,
c = makeCanvas(w, h),
ctx = c.getContext('2d');
const rng = mulberry32(11);
ctx.fillStyle = '#9a948a';
ctx.fillRect(0, 0, w, h);
// A handful of dark maria patches roughly clustered on the near side
// (centered on x ≈ 0.5w in equirectangular). Real maria are basaltic
// floods of ancient impact basins; this is just gestural.
for (let i = 0; i < 9; i++) {
const cx = w * (0.35 + rng() * 0.3);
const cy = h * (0.25 + rng() * 0.5);
const rr = 35 + rng() * 70;
const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, rr);
grd.addColorStop(0, 'rgba(60,55,52,0.65)');
grd.addColorStop(0.6, 'rgba(80,72,68,0.4)');
grd.addColorStop(1, 'rgba(154,148,138,0)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.ellipse(cx, cy, rr, rr * (0.6 + rng() * 0.4), rng() * Math.PI, 0, Math.PI * 2);
ctx.fill();
}
// Crater pocking — same idea as Mercury's, slightly higher contrast so
// the surface still reads as cratered when the photographic map is missing.
for (let i = 0; i < 700; i++) {
const x = rng() * w,
y = rng() * h,
r = 2 + rng() * 12;
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'rgba(40,38,35,0.55)');
grd.addColorStop(0.7, 'rgba(190,184,172,0.3)');
grd.addColorStop(1, 'rgba(190,184,172,0)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
addSpeckles(
ctx,
w,
h,
8000,
rng,
() => `rgba(${(90 + rng() * 60) | 0},${(85 + rng() * 55) | 0},${(80 + rng() * 50) | 0},0.28)`,
0.4,
1.1
);
return new THREE.CanvasTexture(c);
}
// ————————————————————————————————————————————————
// Phobos / Deimos / Miranda — fallbacks for the photographic moon textures
// (#100). Visually distinct from a flat tint so an offline / failed-load
// path still reads as cratered grey rather than a uniform color.
// ————————————————————————————————————————————————
function smallMoonTexture(seed, baseHex, craterCount, craterSize) {
const w = 512,
h = 256;
const c = makeCanvas(w, h);
const ctx = c.getContext('2d');
const rng = mulberry32(seed);
ctx.fillStyle = baseHex;
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < craterCount; i++) {
const x = rng() * w,
y = rng() * h,
r = craterSize[0] + rng() * (craterSize[1] - craterSize[0]);
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'rgba(30,28,25,0.55)');
grd.addColorStop(0.7, 'rgba(255,255,255,0.18)');
grd.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
addSpeckles(
ctx,
w,
h,
2500,
rng,
() => `rgba(${(40 + rng() * 60) | 0},${(40 + rng() * 55) | 0},${(40 + rng() * 50) | 0},0.25)`,
0.4,
1.0
);
return new THREE.CanvasTexture(c);
}
export function phobosTexture() {
return smallMoonTexture(101, '#8a7a68', 600, [2, 14]);
}
export function deimosTexture() {
return smallMoonTexture(102, '#9a8a78', 350, [2, 9]);
}
export function titaniaTexture() {
return smallMoonTexture(104, '#a09690', 500, [3, 16]);
}
export function oberonTexture() {
return smallMoonTexture(105, '#8e857f', 550, [3, 18]);
}
export function mirandaTexture() {
// Brighter base + low-contrast bright/dark albedo bands hinting at Miranda's
// chevron and Inverness coronae without claiming to depict them.
const w = 512,
h = 256;
const c = makeCanvas(w, h);
const ctx = c.getContext('2d');
const rng = mulberry32(103);
ctx.fillStyle = '#a8b0b6';
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 6; i++) {
const cx = rng() * w,
cy = h * (0.25 + rng() * 0.5),
rr = 50 + rng() * 90;
const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, rr);
grd.addColorStop(0, rng() > 0.5 ? 'rgba(70,75,80,0.45)' : 'rgba(220,225,230,0.35)');
grd.addColorStop(1, 'rgba(168,176,182,0)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.ellipse(cx, cy, rr, rr * (0.5 + rng() * 0.5), rng() * Math.PI, 0, Math.PI * 2);
ctx.fill();
}
addSpeckles(
ctx,
w,
h,
2000,
rng,
() =>
`rgba(${(140 + rng() * 70) | 0},${(150 + rng() * 65) | 0},${(160 + rng() * 60) | 0},0.22)`,
0.4,
1.0
);
return new THREE.CanvasTexture(c);
}
export function marsTexture() {
const w = 1024,
h = 512,
c = makeCanvas(w, h),
ctx = c.getContext('2d');
const rng = mulberry32(5);
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, '#8a3a28');
g.addColorStop(0.5, '#c25a38');
g.addColorStop(1, '#7a3020');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < 25; i++) {
ctx.fillStyle = `rgba(${(90 + rng() * 40) | 0},${(45 + rng() * 20) | 0},${(30 + rng() * 15) | 0},0.5)`;
const x = rng() * w,
y = rng() * h,
rr = 20 + rng() * 70;
ctx.beginPath();
ctx.ellipse(x, y, rr, rr * 0.6, rng() * Math.PI, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = 'rgba(245,230,220,0.9)';
ctx.fillRect(0, 0, w, 18);
ctx.fillStyle = 'rgba(245,230,220,0.85)';
ctx.fillRect(0, h - 16, w, 16);
addSpeckles(
ctx,
w,
h,
5000,
rng,
() => `rgba(${(160 + rng() * 50) | 0},${(70 + rng() * 30) | 0},${(45 + rng() * 20) | 0},0.3)`,
0.4,
1.3
);
return new THREE.CanvasTexture(c);
}
// ————————————————————————————————————————————————
// Gas giants — stacked horizontal bands with optional storm spot
// ————————————————————————————————————————————————
function gasGiantTexture(seed, bands) {
const w = 1024,
h = 512,
c = makeCanvas(w, h),
ctx = c.getContext('2d');
const rng = mulberry32(seed);
let y = 0;
while (y < h) {
const bh = (4 + rng() * 28) | 0;
const col = bands[(rng() * bands.length) | 0];
const g = ctx.createLinearGradient(0, y, 0, y + bh);
g.addColorStop(0, col[0]);
g.addColorStop(1, col[1]);
ctx.fillStyle = g;
ctx.fillRect(0, y, w, bh);
y += bh;
}
for (let i = 0; i < 400; i++) {
ctx.fillStyle = `rgba(${(200 + rng() * 55) | 0},${(180 + rng() * 55) | 0},${(140 + rng() * 60) | 0},${0.05 + rng() * 0.1})`;
const yy = rng() * h,
ww = 60 + rng() * 200,
hh = 1 + rng() * 4;
ctx.beginPath();
ctx.ellipse(rng() * w, yy, ww, hh, 0, 0, Math.PI * 2);
ctx.fill();
}
return new THREE.CanvasTexture(c);
}
export function jupiterTexture() {
const t = gasGiantTexture(6, [
['#d9b58a', '#b88a55'],
['#e8cfa2', '#c9a47a'],
['#8a5d38', '#6a422a'],
['#f0dfb8', '#d8bd88'],
['#a87855', '#8a5a3a'],
]);
// Great Red Spot
const ctx = t.image.getContext('2d');
const sx = t.image.width * 0.35,
sy = t.image.height * 0.62;
const grd = ctx.createRadialGradient(sx, sy, 0, sx, sy, 55);
grd.addColorStop(0, 'rgba(180,60,40,0.9)');
grd.addColorStop(1, 'rgba(180,60,40,0)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.ellipse(sx, sy, 55, 22, 0, 0, Math.PI * 2);
ctx.fill();
t.needsUpdate = true;
return t;
}
export function saturnTexture() {
return gasGiantTexture(7, [
['#e8d4a3', '#cbb082'],
['#f2e3bb', '#d9c59a'],
['#c4a474', '#a48555'],
['#eed9a8', '#d4bb85'],
]);
}
export function uranusTexture() {
return gasGiantTexture(8, [
['#bfe3e8', '#a5d5dd'],
['#cfeef2', '#b5dfe6'],
['#9acbd3', '#82b7c0'],
]);
}
export function neptuneTexture() {
const t = gasGiantTexture(9, [
['#3a5fcf', '#2a4aa5'],
['#506fd5', '#3a56b5'],
['#2a4697', '#1e3680'],
]);
// Great Dark Spot
const ctx = t.image.getContext('2d');
const sx = t.image.width * 0.6,
sy = t.image.height * 0.45;
const grd = ctx.createRadialGradient(sx, sy, 0, sx, sy, 45);
grd.addColorStop(0, 'rgba(20,25,60,0.85)');
grd.addColorStop(1, 'rgba(20,25,60,0)');
ctx.fillStyle = grd;
ctx.beginPath();
ctx.ellipse(sx, sy, 45, 20, 0, 0, Math.PI * 2);
ctx.fill();
t.needsUpdate = true;
return t;
}
// ————————————————————————————————————————————————
// Saturn's rings — 1D radial strip with a Cassini division at ~55%.
// ————————————————————————————————————————————————
export function saturnRingTexture() {
const w = 512,
h = 64,
c = makeCanvas(w, h),
ctx = c.getContext('2d');
const rng = mulberry32(10);
for (let x = 0; x < w; x++) {
const t = x / w;
const cassini = Math.abs(t - 0.55) < 0.02 ? 0.15 : 1;
const noise = 0.85 + rng() * 0.3;
const brightness = cassini * noise * (0.5 + 0.5 * Math.sin(t * 30));
const col = `rgba(${(220 * brightness) | 0},${(195 * brightness) | 0},${(145 * brightness) | 0},${0.75 * cassini})`;
ctx.fillStyle = col;
ctx.fillRect(x, 0, 1, h);
}
return new THREE.CanvasTexture(c);
}
// ————————————————————————————————————————————————
// Lookup table: body name → texture function.
// Used by views that iterate PLANETS from src/data/planets.js.
// ————————————————————————————————————————————————
export const TEXTURE_BY_NAME = {
Sun: sunTexture,
Mercury: mercuryTexture,
Venus: venusTexture,
Earth: earthTexture,
Mars: marsTexture,
Jupiter: jupiterTexture,
Saturn: saturnTexture,
Uranus: uranusTexture,
Neptune: neptuneTexture,
};
← Astrarium