b3d-galaxy
Procedural galaxy renderer using Babylon.js SolidParticleSystem. Generates thousands of stars in a spiral arm distribution, each colored by spectral class. All generation is seeded — same seed always produces the same galaxy.
Stars use a custom shader: white-hot center fading to spectral color at edges. A procedural black hole with accretion disk sits at the galaxy center.
Stars are pickable — click one to zoom in and see its star system rendered
in detail. Use getStarAt(index) to retrieve star data and
getStarSystem(index) to get full planet detail for any star.
Use hideStarAt(index) to hide a star (e.g. when replacing it with a
rendered star system) and showStarAt(index) to restore it.
Filter stars by habitability index and/or name using filterStars({ maxHI, nameSearch }) —
non-matching stars are dimmed.
Demo
import { b3d, b3dLight, b3dGalaxy, b3dStarSystem, label3d, slider3d, select3d } from 'tosijs-3d'
import { tosi, elements } from 'tosijs'
const { div, label, input, p, button, span } = elements
const { demo } = tosi({
demo: {
seed: 1234,
starCount: 5000,
radius: 100,
spiralArms: 4,
particleSize: 2.5,
habitability: 5,
nameSearch: '',
selectedStar: '',
},
})
const galaxy = b3dGalaxy({
seed: demo.seed,
starCount: demo.starCount,
radius: demo.radius,
spiralArms: demo.spiralArms,
particleSize: demo.particleSize,
})
// Star system state
let activeStarSystem = null
let activeStarIndex = -1
let sceneEl = null
// Transition: 'galaxy' | 'zooming-in' | 'star-system' | 'zooming-out'
let viewState = 'galaxy'
let transition = { t: 0, savedAlpha: 0, savedBeta: 0, savedRadius: 0, savedTarget: null }
function lerp(a, b, t) { return a + (b - a) * t }
function smoothstep(t) { return t * t * (3 - 2 * t) }
const scene = b3d(
{
frameRate: 60,
// Generation settings live in the dual-presence ⚙ panel (works in VR); the
// free-text star search + selection readout stay a flat overlay (typing needs
// a keyboard). All widgets bind the same demo.* leaves the observers watch.
scenePanel: () => [
label3d({ text: 'Galaxy' }),
select3d({ label: 'habitability', value: demo.habitability, options: [
{ label: 'All', value: 5 },
{ label: 'Robot+', value: 4 },
{ label: 'EVA+', value: 3 },
{ label: 'Survivable+', value: 2 },
{ label: 'Earthlike', value: 1 },
] }),
slider3d({ label: 'stars', value: demo.starCount, min: 1000, max: 20000, step: 1000 }),
slider3d({ label: 'radius', value: demo.radius, min: 50, max: 300, step: 10 }),
slider3d({ label: 'spiral arms', value: demo.spiralArms, min: 1, max: 8, step: 1 }),
slider3d({ label: 'particle size', value: demo.particleSize, min: 0.5, max: 3, step: 0.1 }),
slider3d({ label: 'seed', value: demo.seed, min: 0, max: 65535, step: 1 }),
],
sceneCreated(el, BABYLON) {
sceneEl = el
el.scene.clearColor = new BABYLON.Color4(0.012, 0.008, 0.024, 1)
const camera = new BABYLON.ArcRotateCamera(
'orbit-cam',
-Math.PI / 2,
Math.PI / 4,
200,
BABYLON.Vector3.Zero(),
el.scene
)
camera.lowerRadiusLimit = 2
camera.upperRadiusLimit = 500
camera.minZ = 0.1
camera.maxZ = 2000
camera.attachControl(el.querySelector('canvas'), true)
el.setActiveCamera(camera)
// Transition animation
el.scene.registerBeforeRender(() => {
if (viewState === 'zooming-in') {
transition.t = Math.min(1, transition.t + 0.02)
const t = smoothstep(transition.t)
// Fade galaxy out
galaxy.setVisibility(1 - t)
// Fade star system in
if (activeStarSystem) activeStarSystem.setVisibility(t)
// Zoom camera toward star, then reset to origin for star system view
const starPos = transition.savedTarget
camera.target.x = lerp(starPos.x, 0, t)
camera.target.y = lerp(starPos.y, 0, t)
camera.target.z = lerp(starPos.z, 0, t)
camera.radius = lerp(transition.savedRadius, 100, t)
if (transition.t >= 1) {
viewState = 'star-system'
galaxy.setVisibility(0)
camera.target.set(0, 0, 0)
camera.radius = 100
}
} else if (viewState === 'zooming-out') {
transition.t = Math.min(1, transition.t + 0.02)
const t = smoothstep(transition.t)
// Fade star system out
if (activeStarSystem) activeStarSystem.setVisibility(1 - t)
// Fade galaxy in
galaxy.setVisibility(t)
camera.radius = lerp(100, transition.savedRadius, t)
camera.target.x = lerp(0, transition.savedTarget.x, t)
camera.target.y = lerp(0, transition.savedTarget.y, t)
camera.target.z = lerp(0, transition.savedTarget.z, t)
if (transition.t >= 1) {
viewState = 'galaxy'
if (activeStarSystem) {
activeStarSystem.remove()
activeStarSystem = null
}
galaxy.showStarAt(activeStarIndex)
galaxy.setVisibility(1)
activeStarIndex = -1
demo.selectedStar.value = ''
camera.alpha = transition.savedAlpha
camera.beta = transition.savedBeta
camera.radius = transition.savedRadius
camera.target.set(transition.savedTarget.x, transition.savedTarget.y, transition.savedTarget.z)
}
}
})
// Star picking — click = mousedown + mouseup within 5px
let downX = 0, downY = 0
el.scene.onPointerDown = (evt) => {
if (evt.button !== 0) return
downX = evt.offsetX
downY = evt.offsetY
}
el.scene.onPointerUp = (evt) => {
if (evt.button !== 0 || viewState !== 'galaxy') return
const dx = evt.offsetX - downX
const dy = evt.offsetY - downY
if (dx * dx + dy * dy > 25) return // dragged — not a click
const sps = galaxy.getStarSPS()
const starMesh = galaxy.getStarMesh()
if (!sps || !starMesh) return
// Use Babylon's built-in scene pick + SPS pickedParticle
const pickResult = el.scene.pick(evt.offsetX, evt.offsetY)
if (pickResult.hit && pickResult.pickedMesh === starMesh) {
const picked = sps.pickedParticle(pickResult)
if (picked) {
zoomToStar(picked.idx, camera, el)
}
}
}
},
},
b3dLight({ intensity: 0.1 }),
galaxy,
)
function zoomToStar(idx, camera, el) {
if (activeStarSystem) {
activeStarSystem.remove()
galaxy.showStarAt(activeStarIndex)
}
activeStarIndex = idx
const star = galaxy.getStarAt(idx)
const pos = galaxy.getStarPosition(idx)
if (!star || !pos) return
demo.selectedStar.value = star.name + ' (' + star.spectralType + ', HI ' + star.bestHI + ')'
galaxy.hideStarAt(idx)
// Save camera state for return trip
transition = {
t: 0,
savedAlpha: camera.alpha,
savedBeta: camera.beta,
savedRadius: camera.radius,
savedTarget: { x: camera.target.x, y: camera.target.y, z: camera.target.z },
}
// Create star system at origin, full scale, initially invisible
activeStarSystem = b3dStarSystem({
galaxySeed: demo.seed.value,
starCount: demo.starCount.value,
starIndex: idx,
scale: 5,
orbitScale: 3,
animate: true,
showOrbits: true,
})
el.appendChild(activeStarSystem)
activeStarSystem.setVisibility(0)
viewState = 'zooming-in'
}
function returnToGalaxy() {
if (viewState !== 'star-system') return
transition.t = 0
viewState = 'zooming-out'
}
// Escape key returns to galaxy view
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && activeStarIndex >= 0) returnToGalaxy()
})
const backBtn = button(
{
style: 'display:none; margin-top:4px; cursor:pointer; background:#444; color:white; border:1px solid #888; border-radius:3px; padding:2px 8px; font:12px monospace',
onclick() { returnToGalaxy() },
},
'Back to galaxy'
)
const starLabel = span({ style: 'color:#8cf' })
demo.selectedStar.observe((v) => {
starLabel.textContent = v
backBtn.style.display = v ? 'block' : 'none'
})
// Flat overlay keeps only the star search + selection readout; the generation
// settings all live in the ⚙ scene panel (and in VR).
preview.append(
scene,
div(
{ class: 'debug-panel' },
p('Click a star to zoom in. Esc to return. Generation settings in the ⚙ (VR too).'),
starLabel,
backBtn,
label(
'search ',
input({ type: 'text', placeholder: 'star name', style: 'width:8em; color:white; background:transparent; border:1px solid #666; padding:1px 4px; font:12px monospace', bindValue: demo.nameSearch }),
),
)
)
for (const key of ['seed', 'starCount', 'radius', 'spiralArms', 'particleSize']) {
demo[key].observe(() => {
if (activeStarSystem) {
activeStarSystem.remove()
activeStarSystem = null
activeStarIndex = -1
demo.selectedStar.value = ''
viewState = 'galaxy'
}
galaxy.setVisibility(1)
galaxy.regenerate()
})
}
function applyFilter() {
galaxy.filterStars({
maxHI: Number(demo.habitability.value),
nameSearch: demo.nameSearch.value,
})
}
demo.habitability.observe(applyFilter)
demo.nameSearch.observe(applyFilter)
tosi-b3d {
width: 100%;
height: 100%;
}
.debug-panel {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.6);
color: white;
padding: 8px 12px;
border-radius: 6px;
font: 12px monospace;
display: flex;
flex-direction: column;
gap: 2px;
}
Attributes
| Attribute | Default | Description |
|---|---|---|
seed |
1234 |
Galaxy seed |
starCount |
10000 |
Number of stars |
radius |
100 |
Galaxy radius in scene units |
spiralArms |
4 |
Number of spiral arms |
spiralAngle |
240 |
Spiral arm sweep in degrees |
thickness |
0.06 |
Disk thickness (fraction of radius) |
particleSize |
1.0 |
Base star particle diameter |
coreSize |
2.0 |
Central black hole radius |