Skip to Content
Babylon.js3Darchitectureperformancefrontend

Rendering Poems as a Star Map: Driving a Babylon.js Scene Graph from a Content JSON, Without Shipping the Engine Twice

AUTHENTICATED_ACCESS_GRANTED

The Context: "A Star Chart Built Out of Poetry"

The Story-Teller renders my poems as an explorable 3D star map. Each poem is a planet; each stanza is a moon orbiting it; each line is a star. Selecting a body opens the poem. It is the most personal thing on the site, and it is also the single heaviest page in the portfolio.

While building it I made one decision that mattered more than any shader or camera work, and one that I got wrong badly enough to cost a full re-architecture.

The wrong decision was importing Babylon the obvious way:

import * as BABYLON from "@babylonjs/core";

That is the documented pattern. It is also how I shipped a 7MB bundle for a page whose entire visual vocabulary is spheres and glow.

The right decision was realising that the scene graph should be a rendering of my content model, not a hand-written sculpture. poems.json already described the universe. The code was re-describing it.

The Problem Statement

Render a navigable 3D map from a content file, in a statically exported page, without a heavyweight runtime:

  • Payload: Keep the 3D payload under 1MB gzipped on a page that is otherwise static text.
  • Fidelity: Scene composition derived entirely from poems.json; adding a poem must require zero code changes.
  • Interaction: 60fps orbit, hover labels, click-to-read, on a mid-range laptop.
  • Island safety: The 3D engine must load only on this route and must dispose cleanly.
  • Upgradability: Babylon's API surface must be auditable in a single file.

Visual Architecture

The Core Logic / Process

1. The Import Surface Is an Architecture Boundary

@babylonjs/core's barrel export pulls in the entire engine, including material libraries and NodeMaterial editor blocks that this scene never instantiates. Measured across six routes, the barrel import shipped roughly 7.0MB raw / 1.87MB gzip across ~539 module requests.

Deep imports are tree-shakeable, but Babylon registers behaviour through module side effects: importing standardMaterial attaches the material class to the engine, importing meshBuilder attaches the individual builders. Get that list wrong and the scene fails at runtime with errors like "Material is not ready" — a green build, a blank canvas.

So the surface is curated once, in one file:

// utils/babylon.ts — the only place Babylon is imported from.
import "@babylonjs/core/Materials/standardMaterial"; // side effect: registration
import "@babylonjs/core/Meshes/Builders/sphereBuilder"; // side effect: MeshBuilder.CreateSphere

import { Engine } from "@babylonjs/core/Engines/engine";
import { Scene } from "@babylonjs/core/scene";
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera";
import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder";
import { GlowLayer } from "@babylonjs/core/Layers/glowLayer";

The value is not the byte saving alone. It is that when Babylon ships a breaking change, there is exactly one file to audit, and the failure mode of getting it wrong is loud and immediate rather than intermittent and mysterious.

2. Content Drives Geometry, Not the Reverse

poems.json is authored content and the renderer is a pure function of it:

interface BodyDescriptor {
  name: string;
  position: Vector3;
  radius: number;
  color: Color3;
  moons: { name: string; radius: number; orbit: number }[];
}

function buildPlanets(data: PoemData[]): BodyDescriptor[] {
  return data.map((poem, i) => {
    // Fibonacci-ish distribution: even angular spacing, no clustering
    const theta = Math.acos(1 - (2 * (i + 0.5)) / data.length);
    const phi = Math.PI * (1 + Math.sqrt(5)) * i;
    return {
      name: poem.title,
      position: new Vector3(
        Math.cos(phi) * Math.sin(theta) * 40,
        Math.sin(phi) * Math.sin(theta) * 40,
        Math.cos(theta) * 40,
      ),
      radius: 1.2 + Math.min(poem.stanzas.length, 12) * 0.18,
      color: hueToColor(poem.title.length),
      moons: poem.stanzas.map((s, j) => ({
        name: s.split("\n")[0].slice(0, 28),
        radius: 0.25,
        orbit: 2.2 + j * 0.35,
      })),
    };
  });
}

Radius scales with stanza count, so a long poem is visibly a larger world. Moon count equals stanza count. Distribution uses a golden-angle spiral rather than uniform random, because random placement always produces clumps and gaps — I have tried it, it looks lopsided, and deterministic trigonometric placement is both prettier and reproducible.

The payoff: adding a poem is a JSON edit. No component changes, no imports, no registry. The renderer does not know or care how many poems exist.

3. One Material Pass, Shared Glow

Two passes over the plan:

  1. Geometry — create every mesh, position it, parent it to a TransformNode per planet so moons orbit as a unit.
  2. Material — assign emissive StandardMaterial and let a single GlowLayer handle all glow. Creating a GlowLayer per planet is the obvious mistake and it costs a full-screen render pass each time.

Parenting moons to a TransformNode is what makes orbit animation a single rotation of the parent instead of N per-moon matrix updates. On a laptop this is the difference between 60fps and 31fps with a dozen poems loaded.

4. Picking Without a Raycast Per Frame

Picking runs on PointerEventTypes.POINTERMOVE but is throttled to animation frames, and the scene's mesh.isPickable = false is set everywhere except the planet bodies:

scene.onPointerObservable.add((info) => {
  if (info.type !== PointerEventTypes.POINTERMOVE) return;
  const pick = scene.pick(scene.pointerX, scene.pointerY);
  const hit = pick?.pickedMesh?.metadata?.poem;
  setHoverLabel(
    hit ? { name: hit.title, x: scene.pointerX, y: scene.pointerY } : null,
  );
});

Babylon does provide a pointer-move throttle internally, but its default still walks a scene containing several hundred star meshes. Filtering isPickable up front shrinks the candidate set from hundreds to a dozen and makes hover feel instant.

The "Baigan" Take (Expert Insights)

Every general tutorial on Babylon shows import * as BABYLON. For a full 3D application that is correct and I would not criticise it. For a single route on a portfolio, it is an architectural failure wearing a tutorial's clothing.

baigan ka Babylon is the belief that "the engine is one dependency, so import it however is convenient." Convenience at the import site becomes a 1.87MB gzip tax paid by every visitor who opens this page, and an upgrade that requires auditing the entire engine surface instead of one curated file.

Three lessons:

  1. Side-effect imports are not optional. After curating the deep imports, my scene broke with "Material is not ready" on first load. The standardMaterial side-effect import was missing. This failure mode is uniquely nasty: a green build, a passing typecheck, a blank canvas. That is precisely why the surface lives in one file with a comment explaining why each line exists — the comment is the documentation, because deleting an apparently-unused import breaks the page.

  2. Fidelity comes from the data model, not the renderer. The first version had positions hardcoded in the component. It was beautiful and it meant every new poem required editing JSX. Inverting to "JSON describes the universe, the renderer interprets it" made the page extensible and made the component honest about what it was.

  3. Dispose or leak. Three Babylon pages left open in three tabs is three WebGL contexts, and browsers cap those at around sixteen. The effect cleanup disposes the scene and the engine; without it the tab eventually takes down every other 3D page in the session, which is a spectacularly confusing bug to diagnose.

The transferable point is bigger than Babylon: when a dependency has a registration-by-side-effect API, the import list is part of your architecture and deserves the same scrutiny as your database schema. Centralise it, comment why each entry exists, and upgrade it deliberately in one place.

Performance Checklist

  • Import Babylon only from the curated utils/babylon surface — never the barrel.
  • Keep every required side-effect import with a comment explaining its registration role.
  • Drive all geometry from the content JSON; adding content must require no code change.
  • Use golden-angle distribution rather than random placement for even spatial spread.
  • Parent orbiting meshes to a TransformNode and animate the parent.
  • Share one GlowLayer across the scene; never one per mesh.
  • Set isPickable = false on decorative meshes to shrink the picking candidate set.
  • Dispose the scene and engine in the effect cleanup to avoid the WebGL context cap.
  • Bundle-split the engine so it downloads only on routes that need it.
  • Re-audit the curated import list on every Babylon major upgrade, in one place.
© 2026 PRANABESH_VAULT_ENCRYPTION_V4CONFIDENTIAL_REPRODUCTION_PROHIBITED