Derive a surface for every body that was never photographed
Fifteen bodies here have a real photograph. Every exoplanet does not, and never will on current instruments — none has ever been imaged — and nor do several of the solar system's own moons. Those all shared one crude stand-in: a few noisy bands tinted by category, cached per colour, so every exoplanet in the app was literally the same picture. They now get a surface reasoned from what has actually been measured. The chain is standard at every link. A host star's luminosity comes from its catalogued apparent magnitude and its parallax distance — that pair is exactly an absolute magnitude — plus a bolometric correction for its spectral class. The correction is not optional: an M dwarf radiates most of its light in the infrared, so its visual magnitude understates it more than tenfold, and M dwarfs are what most nearby planet hosts are. Luminosity and the semi-major axis then give an equilibrium temperature, mass and radius give a bulk density, and size, temperature and density together give a class of world. Checked against the solar system the temperatures land on Earth 255 K, Jupiter 112 K, Neptune 46 K, all within a kelvin or two of published values, and 51 Pegasi b comes out at 1227 K against a published 1200. Each class carries a palette reasoned from its chemistry — methane absorbs red light, which is why the ice giants are blue — and a structure: zonal bands for a body with a fluid envelope, because a rapidly rotating atmosphere organises into them, and fractal terrain for one with a solid surface. Polar caps grow and shrink with the derived temperature, which is the clearest visible consequence of the whole chain. The generator samples three-dimensional noise along the sphere rather than a flat field, so there is no seam to stitch at the antimeridian and no pinching at the poles, and it writes into a byte array rather than a canvas — a pure function, testable, with no 2D context to be unavailable. Two things the derivation cannot do, both stated on screen next to the measurements it rests on. Equilibrium temperature ignores greenhouse warming and internal heat, so Venus comes out at 300 K against a real surface of 737 K and Io, kept molten by tides, classifies as ice. And these are illustrations: reasoned, but not observations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PlanetAppearance, PlanetClass, paletteFor, planetAppearance } from '../astro/planet-appearance';
|
||||
import { averageColor, planetTexture, renderPlanetTexture } from './procedural-planet-texture';
|
||||
|
||||
const SIZE = { width: 64, height: 32 };
|
||||
const ALL_CLASSES: PlanetClass[] = ['lava', 'scorched', 'iron', 'rocky', 'temperate', 'icy', 'subNeptune', 'iceGiant', 'gasGiant', 'hotGasGiant'];
|
||||
|
||||
function appearanceOf(planetClass: PlanetClass, overrides: Partial<PlanetAppearance> = {}): PlanetAppearance {
|
||||
return {
|
||||
planetClass,
|
||||
palette: paletteFor(planetClass),
|
||||
equilibriumTemperatureK: 250,
|
||||
bulkDensityGramsPerCm3: 5,
|
||||
polarCapExtentDeg: null,
|
||||
seed: 12345,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
/** RGB of one texel, 0-255. */
|
||||
function texelAt(pixels: Uint8Array, width: number, column: number, row: number): [number, number, number] {
|
||||
const offset = (row * width + column) * 4;
|
||||
return [pixels[offset], pixels[offset + 1], pixels[offset + 2]];
|
||||
}
|
||||
|
||||
function difference(a: readonly number[], b: readonly number[]): number {
|
||||
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]);
|
||||
}
|
||||
|
||||
describe('renderPlanetTexture', () => {
|
||||
it('fills an opaque RGBA buffer of the requested size', () => {
|
||||
const pixels = renderPlanetTexture(appearanceOf('rocky'), SIZE);
|
||||
|
||||
expect(pixels).toHaveLength(SIZE.width * SIZE.height * 4);
|
||||
for (let index = 3; index < pixels.length; index += 4) {
|
||||
expect(pixels[index]).toBe(255);
|
||||
}
|
||||
});
|
||||
|
||||
it('is the same surface every time, so a world does not change between visits', () => {
|
||||
const first = renderPlanetTexture(appearanceOf('gasGiant'), SIZE);
|
||||
const second = renderPlanetTexture(appearanceOf('gasGiant'), SIZE);
|
||||
expect(Array.from(second)).toEqual(Array.from(first));
|
||||
});
|
||||
|
||||
it('gives two different worlds two different surfaces', () => {
|
||||
const a = renderPlanetTexture(appearanceOf('rocky', { seed: 1 }), SIZE);
|
||||
const b = renderPlanetTexture(appearanceOf('rocky', { seed: 2 }), SIZE);
|
||||
expect(Array.from(a)).not.toEqual(Array.from(b));
|
||||
});
|
||||
|
||||
it('wraps continuously around the seam, since the noise is sampled on the sphere', () => {
|
||||
// The reason for sampling a solid field along the sphere rather than a plane: 2D noise would
|
||||
// have to be stitched at this seam by hand, and would still pinch at the poles.
|
||||
const pixels = renderPlanetTexture(appearanceOf('rocky'), { width: 256, height: 128 });
|
||||
for (const row of [10, 64, 120]) {
|
||||
const left = texelAt(pixels, 256, 0, row);
|
||||
const right = texelAt(pixels, 256, 255, row);
|
||||
const neighbouring = texelAt(pixels, 256, 1, row);
|
||||
// The two edge columns are neighbours on the sphere, so they must differ no more than any
|
||||
// other adjacent pair does.
|
||||
expect(difference(left, right)).toBeLessThanOrEqual(difference(left, neighbouring) + 12);
|
||||
}
|
||||
});
|
||||
|
||||
it('varies with latitude, which is what makes a banded world banded', () => {
|
||||
const pixels = renderPlanetTexture(appearanceOf('gasGiant'), { width: 128, height: 64 });
|
||||
const column = 40;
|
||||
let maximumStep = 0;
|
||||
for (let row = 1; row < 64; row++) {
|
||||
maximumStep = Math.max(maximumStep, difference(texelAt(pixels, 128, column, row), texelAt(pixels, 128, column, row - 1)));
|
||||
}
|
||||
expect(maximumStep).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('paints a polar cap when the derived temperature calls for one, and not otherwise', () => {
|
||||
const withCap = renderPlanetTexture(appearanceOf('temperate', { polarCapExtentDeg: 40 }), SIZE);
|
||||
const without = renderPlanetTexture(appearanceOf('temperate', { polarCapExtentDeg: null }), SIZE);
|
||||
|
||||
const pole = 0;
|
||||
const equator = SIZE.height / 2;
|
||||
// At the pole the capped world is markedly brighter; at the equator the two agree.
|
||||
const capPole = texelAt(withCap, SIZE.width, 10, pole);
|
||||
const barePole = texelAt(without, SIZE.width, 10, pole);
|
||||
expect(capPole[0] + capPole[1] + capPole[2]).toBeGreaterThan(barePole[0] + barePole[1] + barePole[2] + 60);
|
||||
expect(difference(texelAt(withCap, SIZE.width, 10, equator), texelAt(without, SIZE.width, 10, equator))).toBe(0);
|
||||
});
|
||||
|
||||
it('grows the cap further toward the equator as the world gets colder', () => {
|
||||
const brightnessAt = (extent: number, row: number): number => {
|
||||
const pixels = renderPlanetTexture(appearanceOf('temperate', { polarCapExtentDeg: extent }), SIZE);
|
||||
const [r, g, b] = texelAt(pixels, SIZE.width, 20, row);
|
||||
return r + g + b;
|
||||
};
|
||||
const midLatitude = 6;
|
||||
expect(brightnessAt(80, midLatitude)).toBeGreaterThan(brightnessAt(20, midLatitude));
|
||||
});
|
||||
|
||||
it('draws a banded world and a terrain world differently from the same seed', () => {
|
||||
const banded = renderPlanetTexture(appearanceOf('gasGiant'), SIZE);
|
||||
const terrain = renderPlanetTexture(appearanceOf('rocky'), SIZE);
|
||||
expect(Array.from(banded)).not.toEqual(Array.from(terrain));
|
||||
});
|
||||
|
||||
it('keeps a hot giant red and an ice giant blue, end to end', () => {
|
||||
const hot = averageColor(renderPlanetTexture(appearanceOf('hotGasGiant'), SIZE));
|
||||
const ice = averageColor(renderPlanetTexture(appearanceOf('iceGiant'), SIZE));
|
||||
|
||||
expect(hot.r).toBeGreaterThan(hot.b);
|
||||
expect(ice.b).toBeGreaterThan(ice.r);
|
||||
});
|
||||
|
||||
it('produces no NaN or out-of-range bytes for any class', () => {
|
||||
for (const planetClass of ALL_CLASSES) {
|
||||
const pixels = renderPlanetTexture(appearanceOf(planetClass, { polarCapExtentDeg: 30 }), SIZE);
|
||||
for (const value of pixels) {
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThanOrEqual(255);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('planetTexture', () => {
|
||||
it('builds a data texture at the requested size, with no canvas involved', () => {
|
||||
// A DataTexture rather than a CanvasTexture: the pixels are computed, not drawn, so this
|
||||
// works in an environment with no 2D context at all — which is this one.
|
||||
const texture = planetTexture(planetAppearance({ id: 'earth', radiusEarth: 1, massEarth: 1, semiMajorAxisAu: 1, hostLuminositySolar: 1 }), SIZE);
|
||||
|
||||
expect(texture.image.width).toBe(SIZE.width);
|
||||
expect(texture.image.height).toBe(SIZE.height);
|
||||
expect(texture.image.data).toHaveLength(SIZE.width * SIZE.height * 4);
|
||||
});
|
||||
|
||||
it('caches per body and size, so a system of planets is not re-rendered every frame', () => {
|
||||
const appearance = planetAppearance({ id: 'mars', radiusEarth: 0.53, semiMajorAxisAu: 1.52, hostLuminositySolar: 1 });
|
||||
expect(planetTexture(appearance, SIZE)).toBe(planetTexture(appearance, SIZE));
|
||||
expect(planetTexture(appearance, SIZE)).not.toBe(planetTexture(appearance, { width: 32, height: 16 }));
|
||||
});
|
||||
|
||||
it('wraps in longitude and clamps in latitude, matching what the sphere actually does', () => {
|
||||
const texture = planetTexture(appearanceOf('icy'), SIZE);
|
||||
expect(texture.wrapS).toBe(THREE.RepeatWrapping);
|
||||
expect(texture.wrapT).toBe(THREE.ClampToEdgeWrapping);
|
||||
});
|
||||
});
|
||||
|
||||
describe('averageColor', () => {
|
||||
it('averages a uniform buffer to that colour', () => {
|
||||
const pixels = new Uint8Array(16);
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
pixels.set([255, 128, 0, 255], index);
|
||||
}
|
||||
const average = averageColor(pixels);
|
||||
expect(average.r).toBeCloseTo(1, 6);
|
||||
expect(average.g).toBeCloseTo(128 / 255, 6);
|
||||
expect(average.b).toBeCloseTo(0, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
|
||||
import { PlanetAppearance, Rgb } from '../astro/planet-appearance';
|
||||
|
||||
/**
|
||||
* Paints an equirectangular surface for a world from its derived appearance.
|
||||
*
|
||||
* Two things shape it, and both come out of the physics rather than out of taste. A body with a
|
||||
* fluid envelope and no surface gets zonal *bands*, because a rapidly rotating atmosphere
|
||||
* organises into them — that is why Jupiter looks the way it does. A body with a solid surface
|
||||
* gets *terrain*, fractal highlands and basins, because that is what an impacted, eroded crust
|
||||
* looks like at planetary scale. The polar caps then grow and shrink with the derived
|
||||
* equilibrium temperature.
|
||||
*
|
||||
* Written against a plain `Uint8Array` rather than a canvas, which makes it a pure function:
|
||||
* fully testable with no DOM, no 2D context to be unavailable, and no per-pixel draw calls.
|
||||
*/
|
||||
|
||||
/** Size for the body-detail view, where the surface fills the screen. */
|
||||
export const DETAIL_TEXTURE_WIDTH = 512;
|
||||
export const DETAIL_TEXTURE_HEIGHT = 256;
|
||||
/**
|
||||
* Size for a system-view marker, which is a few pixels across. Deliberately tiny: a system can
|
||||
* hold twenty bodies and they are all generated at once as the camera arrives, so this is the
|
||||
* size at which that whole set costs less than a frame.
|
||||
*/
|
||||
export const MARKER_TEXTURE_WIDTH = 32;
|
||||
export const MARKER_TEXTURE_HEIGHT = 16;
|
||||
|
||||
const TERRAIN_OCTAVES = 4;
|
||||
const ROUGHNESS_OCTAVES = 3;
|
||||
const BAND_TURBULENCE_OCTAVES = 3;
|
||||
/** Zonal bands per hemisphere, varied a little per body so no two giants are identical. */
|
||||
const MIN_BANDS = 7;
|
||||
const MAX_BANDS = 14;
|
||||
|
||||
/** Hash of three lattice coordinates and a seed to a value in [0, 1). */
|
||||
function hash3(x: number, y: number, z: number, seed: number): number {
|
||||
let h = seed ^ Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(z | 0, 2147483647);
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
||||
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
/** Hermite fade, so the interpolated field has no visible lattice creases. */
|
||||
function fade(t: number): number {
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Value noise sampled in three dimensions.
|
||||
*
|
||||
* Three rather than two on purpose: the texture is equirectangular, so 2D noise would have to
|
||||
* be made to wrap by hand at the seam and would still pinch at the poles. Sampling a solid
|
||||
* field along the sphere's own surface has neither problem — the field is continuous
|
||||
* everywhere the sphere is.
|
||||
*/
|
||||
function valueNoise3(x: number, y: number, z: number, seed: number): number {
|
||||
const xi = Math.floor(x);
|
||||
const yi = Math.floor(y);
|
||||
const zi = Math.floor(z);
|
||||
const xf = fade(x - xi);
|
||||
const yf = fade(y - yi);
|
||||
const zf = fade(z - zi);
|
||||
|
||||
const c000 = hash3(xi, yi, zi, seed);
|
||||
const c100 = hash3(xi + 1, yi, zi, seed);
|
||||
const c010 = hash3(xi, yi + 1, zi, seed);
|
||||
const c110 = hash3(xi + 1, yi + 1, zi, seed);
|
||||
const c001 = hash3(xi, yi, zi + 1, seed);
|
||||
const c101 = hash3(xi + 1, yi, zi + 1, seed);
|
||||
const c011 = hash3(xi, yi + 1, zi + 1, seed);
|
||||
const c111 = hash3(xi + 1, yi + 1, zi + 1, seed);
|
||||
|
||||
const x00 = c000 + (c100 - c000) * xf;
|
||||
const x10 = c010 + (c110 - c010) * xf;
|
||||
const x01 = c001 + (c101 - c001) * xf;
|
||||
const x11 = c011 + (c111 - c011) * xf;
|
||||
const y0 = x00 + (x10 - x00) * yf;
|
||||
const y1 = x01 + (x11 - x01) * yf;
|
||||
|
||||
return y0 + (y1 - y0) * zf;
|
||||
}
|
||||
|
||||
/** Fractal Brownian motion: octaves of value noise at doubling frequency, halving amplitude. */
|
||||
function fbm(x: number, y: number, z: number, seed: number, octaves: number): number {
|
||||
let amplitude = 1;
|
||||
let frequency = 1;
|
||||
let sum = 0;
|
||||
let total = 0;
|
||||
|
||||
for (let octave = 0; octave < octaves; octave++) {
|
||||
sum += amplitude * valueNoise3(x * frequency, y * frequency, z * frequency, seed + octave * 7919);
|
||||
total += amplitude;
|
||||
amplitude *= 0.5;
|
||||
frequency *= 2;
|
||||
}
|
||||
|
||||
return sum / total;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
}
|
||||
|
||||
function mix(a: Rgb, b: Rgb, t: number): [number, number, number] {
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
||||
}
|
||||
|
||||
/** Three-stop ramp across the palette's low, mid and high tones. */
|
||||
function ramp(appearance: PlanetAppearance, t: number): [number, number, number] {
|
||||
const { low, mid, high } = appearance.palette;
|
||||
const clamped = clamp01(t);
|
||||
return clamped < 0.5 ? mix(low, mid, clamped * 2) : mix(mid, high, (clamped - 0.5) * 2);
|
||||
}
|
||||
|
||||
/** Pulls a value toward or away from the midpoint, by the palette's contrast. */
|
||||
function applyContrast(value: number, contrast: number): number {
|
||||
return clamp01(0.5 + (value - 0.5) * (0.4 + contrast * 1.2));
|
||||
}
|
||||
|
||||
export interface PlanetTextureSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the surface into an RGBA byte array, row-major from the north pole down, ready to
|
||||
* hand to a `DataTexture`.
|
||||
*/
|
||||
export function renderPlanetTexture(appearance: PlanetAppearance, size: PlanetTextureSize): Uint8Array {
|
||||
const { width, height } = size;
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
const { palette, seed } = appearance;
|
||||
const banded = palette.structure === 'banded';
|
||||
// Band count is stable per body but not identical between bodies, so a system of giants does
|
||||
// not read as the same planet drawn several times.
|
||||
const bandCount = MIN_BANDS + (seed % (MAX_BANDS - MIN_BANDS + 1));
|
||||
const capExtent = appearance.polarCapExtentDeg;
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
// Texel centres, so the poles are sampled just inside the surface rather than exactly on it.
|
||||
const v = (row + 0.5) / height;
|
||||
const latitude = (0.5 - v) * Math.PI;
|
||||
const cosLatitude = Math.cos(latitude);
|
||||
const sinLatitude = Math.sin(latitude);
|
||||
|
||||
for (let column = 0; column < width; column++) {
|
||||
const u = (column + 0.5) / width;
|
||||
const longitude = u * Math.PI * 2;
|
||||
// The point on the unit sphere this texel maps to — the noise is sampled there.
|
||||
const px = cosLatitude * Math.cos(longitude);
|
||||
const py = sinLatitude;
|
||||
const pz = cosLatitude * Math.sin(longitude);
|
||||
|
||||
let tone: number;
|
||||
if (banded) {
|
||||
// Latitude, pushed around by turbulence, then folded into zonal bands. The turbulence is
|
||||
// what makes a belt wander and braid rather than sit as a perfect stripe.
|
||||
const turbulence = fbm(px * 2.2, py * 2.2, pz * 2.2, seed, BAND_TURBULENCE_OCTAVES) - 0.5;
|
||||
// Stretched along longitude and squeezed in latitude, which is what shear does to a
|
||||
// cloud: the streaks run round the planet rather than across it.
|
||||
const fine = fbm(px * 6, py * 30, pz * 6, seed + 101, 3) - 0.5;
|
||||
const warped = latitude + turbulence * 0.32 + fine * 0.05;
|
||||
tone = 0.5 + 0.5 * Math.sin(warped * bandCount);
|
||||
tone = tone * 0.85 + (fine + 0.5) * 0.15;
|
||||
} else {
|
||||
// Broad landmasses, then finer detail on top of them. The ridged term is the same noise
|
||||
// folded about its midpoint, which turns smooth hills into creases — the difference
|
||||
// between a surface that reads as cloud and one that reads as ground.
|
||||
const continents = fbm(px * 2.4, py * 2.4, pz * 2.4, seed, TERRAIN_OCTAVES);
|
||||
const roughness = fbm(px * 18, py * 18, pz * 18, seed + 313, ROUGHNESS_OCTAVES);
|
||||
const ridged = 1 - Math.abs(2 * roughness - 1);
|
||||
tone = continents * 0.68 + roughness * 0.18 + ridged * 0.14;
|
||||
}
|
||||
|
||||
let [r, g, b] = ramp(appearance, applyContrast(tone, palette.contrast));
|
||||
|
||||
if (capExtent !== null && capExtent > 0) {
|
||||
// Caps are ragged rather than a clean circle: the same surface noise that shapes the
|
||||
// terrain decides how far the ice reaches at each longitude.
|
||||
const latitudeFromPoleDeg = 90 - (Math.abs(latitude) * 180) / Math.PI;
|
||||
const edge = capExtent * (0.85 + 0.3 * fbm(px * 6, py * 6, pz * 6, seed + 977, 3));
|
||||
const coverage = clamp01((edge - latitudeFromPoleDeg) / Math.max(edge * 0.35, 1));
|
||||
if (coverage > 0) {
|
||||
[r, g, b] = mix([r, g, b], palette.cap, coverage);
|
||||
}
|
||||
}
|
||||
|
||||
const offset = (row * width + column) * 4;
|
||||
pixels[offset] = Math.round(clamp01(r) * 255);
|
||||
pixels[offset + 1] = Math.round(clamp01(g) * 255);
|
||||
pixels[offset + 2] = Math.round(clamp01(b) * 255);
|
||||
pixels[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return pixels;
|
||||
}
|
||||
|
||||
const textureCache = new Map<string, THREE.DataTexture>();
|
||||
|
||||
/**
|
||||
* The rendered surface as a Three.js texture, cached per body and size.
|
||||
*
|
||||
* A `DataTexture` rather than a `CanvasTexture`: the pixels are computed rather than drawn, so
|
||||
* there is no reason to route them through a 2D context that may not exist — which also means
|
||||
* this works under a headless test environment where canvas rendering does not.
|
||||
*/
|
||||
export function planetTexture(appearance: PlanetAppearance, size: PlanetTextureSize = { width: DETAIL_TEXTURE_WIDTH, height: DETAIL_TEXTURE_HEIGHT }): THREE.DataTexture {
|
||||
const key = `${appearance.seed}:${appearance.planetClass}:${appearance.polarCapExtentDeg ?? 'none'}:${size.width}x${size.height}`;
|
||||
const cached = textureCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const texture = new THREE.DataTexture(renderPlanetTexture(appearance, size), size.width, size.height, THREE.RGBAFormat);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
// Wraps in longitude — the noise is continuous across the seam — but is clamped in latitude,
|
||||
// where there is nothing beyond the pole to wrap to.
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.needsUpdate = true;
|
||||
|
||||
textureCache.set(key, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
/** Average colour of a rendered surface, for anything too small to show the texture itself. */
|
||||
export function averageColor(pixels: Uint8Array): THREE.Color {
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
const count = pixels.length / 4;
|
||||
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
r += pixels[index];
|
||||
g += pixels[index + 1];
|
||||
b += pixels[index + 2];
|
||||
}
|
||||
|
||||
return new THREE.Color(r / count / 255, g / count / 255, b / count / 255);
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import * as THREE from 'three/webgpu';
|
||||
|
||||
/**
|
||||
* Real NASA/ESA/USGS photography baked into `src/assets/textures/bodies/` at build time,
|
||||
* keyed by the same ids used in `bodies.json`. Bodies without an entry here (most exoplanets,
|
||||
* a few moons whose photo wasn't sourced this round, and any future body) fall back to
|
||||
* `proceduralBodyTexture()` below rather than a flat color.
|
||||
* keyed by the same ids used in `bodies.json`.
|
||||
*
|
||||
* This map is the whole of what has actually been photographed. Everything else — every
|
||||
* exoplanet, since not one has ever been imaged, and the moons no probe returned a usable map
|
||||
* of — falls through to `procedural-planet-texture.ts`, which derives a surface from the body's
|
||||
* own measured size, mass, orbit and host star instead.
|
||||
*
|
||||
* Provenance (all public domain NASA/JPL or CC BY 4.0 Solar System Scope, via Wikimedia
|
||||
* Commons — see each file's Commons page for the original credit line):
|
||||
@@ -80,61 +83,3 @@ export function loadCachedTexture(path: string): THREE.Texture {
|
||||
loadedTextures.set(path, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
const proceduralTextureCache = new Map<string, THREE.CanvasTexture>();
|
||||
|
||||
/**
|
||||
* Generates a simple procedural surface for bodies with no real photograph available — mainly
|
||||
* exoplanets, whose actual surfaces have never been directly imaged. This is an honest artistic
|
||||
* stand-in (mottled bands tinted by the body's classification color), not a fabricated "real"
|
||||
* texture, and is cached per color so repeated exoplanets of the same kind share one canvas.
|
||||
* Returns `undefined` if 2D canvas rendering isn't available (e.g. under a test/jsdom
|
||||
* environment with no canvas backend); callers should fall back to a flat material color.
|
||||
*/
|
||||
export function proceduralBodyTexture(baseColor: THREE.ColorRepresentation): THREE.CanvasTexture | undefined {
|
||||
const key = new THREE.Color(baseColor).getHexString();
|
||||
const cached = proceduralTextureCache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const size = 256;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const base = new THREE.Color(baseColor);
|
||||
const light = base.clone().offsetHSL(0, -0.15, 0.14);
|
||||
const dark = base.clone().offsetHSL(0, 0.05, -0.16);
|
||||
|
||||
context.fillStyle = `#${base.getHexString()}`;
|
||||
context.fillRect(0, 0, size, size);
|
||||
|
||||
// A handful of horizontal-ish noisy bands, reminiscent of banded gas giants / mottled rock,
|
||||
// without claiming to depict any specific real surface feature.
|
||||
let seed = key.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) || 1;
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
|
||||
const bandCount = 10;
|
||||
for (let i = 0; i < bandCount; i++) {
|
||||
const y = (i / bandCount) * size + random() * (size / bandCount) * 0.4;
|
||||
const height = size / bandCount * (0.5 + random() * 0.6);
|
||||
context.fillStyle = `#${(random() > 0.5 ? light : dark).getHexString()}`;
|
||||
context.globalAlpha = 0.35 + random() * 0.25;
|
||||
context.fillRect(0, y, size, height);
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.wrapS = THREE.RepeatWrapping;
|
||||
proceduralTextureCache.set(key, texture);
|
||||
return texture;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user