Skip to content
reacttypescript3dportfoliogamedev

Spline Is a Crutch. Here's What I Built Instead.

Basil Francis Alajid
March 28, 2026
5 min read (1,016 words)β€”

Most developer portfolios have a skills section. A grid of icons. Maybe progress bars if they're feeling bold. I looked at mine and decided the skills section itself should be the demonstration.

The result: 11 procedural celestial objects -- black holes, spiral galaxies, pulsars, nebulae -- built entirely from math. No texture files. No Blender exports. No Spline. 15KB of TypeScript that replaced a 263KB binary blob.

Problem

Spline was my first attempt at a 3D hero. It looked nice in the editor. Then reality hit.

It shipped as an opaque 263KB binary that I could not inspect, modify, or debug. It broke on mobile. Hover interactions required fighting Spline's runtime API instead of using React state. And if Spline's CDN went down, my hero was a blank div.

The real issue: I was outsourcing the most visible part of my portfolio to a tool I didn't understand. That's not engineering. That's decoration.

I deleted it.

Constraints

The replacement had to satisfy four non-negotiable requirements:

  1. Zero external assets. No textures, no CDN, no binary blobs. Everything ships as code.
  2. Runs on a mid-range Android phone. Not "degrades gracefully" -- actually runs.
  3. Accessible. Screen readers get the same information through a different channel.
  4. Every object means something. No decorative geometry. Each celestial type represents a specific technology with an intentional metaphor.

System

React Three Fiber + drei + postprocessing. Zustand for hover/active state. Four layers:

  1. Constellation data -- typed array of star definitions with positions, colors, sizes, celestial types
  2. StarNode -- hover/click state, spring animations, delegates to the correct celestial renderer
  3. 11 Celestial components -- one per technology, all procedural
  4. NebulaParticles -- background depth (3800 particles on desktop, 1000 on mobile)

The Mapping

Every tech-to-celestial assignment was deliberate:

  • TypeScript = Black Hole. The gravitational center of every project. Everything gets pulled into its type system.
  • React/Next.js = Ringed Planet. Solid core, orbiting ecosystems (SSR, RSC, middleware).
  • React Native = Comet. Mobile, always in motion, orbiting the React core.
  • Rust/WASM = Star. Raw energy, copper glow. The hottest thing in the stack.
  • Python = Nebula. Diffuse, flexible, everywhere in softer ways.
  • Node.js = Spiral Galaxy. 2000 particles in three logarithmic arms. A massive ecosystem spiraling outward.
  • PostgreSQL = Binary Star. Two spheres in mutual orbit. Reads and writes. Data and schema.
  • Docker = Gas Giant. Big, banded, atmospheric layers of configuration.
  • Playwright = Pulsar. Precise sweeping beams of test coverage at 2 rad/s.
  • Git = White Dwarf. Tiny, intensely bright, foundational.
  • Three.js/R3F = Planetary Nebula. Self-referential. The constellation describing itself.

Execution

The Black Hole

Three layers: a pure black sphere (event horizon), two counter-rotating accretion disks (torusGeometry), and an additive-blended glow sphere.

tsx
<mesh ref={disk1Ref} rotation={[Math.PI / 3, 0.2, 0]}>
  <torusGeometry args={[size * 1.6, size * 0.18, 16, 128]} />
  <meshStandardMaterial
    color={color}
    emissive={color}
    emissiveIntensity={intensity * 1.5}
    transparent opacity={0.85}
    side={THREE.DoubleSide}
    toneMapped={false}
  />
</mesh>

The counter-rotation sells it. Primary disk at 0.4 rad/s, secondary at -0.25. The speed difference creates visual tension.

Critical detail: toneMapped={false} on every emissive material. Without this, Bloom postprocessing never sees values above 1.0 and your emissiveIntensity of 9.0 gets clamped before Bloom can detect it. I lost two hours to this. If your emissive materials aren't glowing with Bloom enabled, this is almost certainly why.

The Spiral Galaxy

Most math-heavy object. 2000 particles in three logarithmic spiral arms:

tsx
const radius = Math.pow(Math.random(), 2) * size * 5;
const branch = ((i % 3) / 3) * Math.PI * 2;
const spin = radius * 1.5;

pos[i * 3]     = Math.cos(branch + spin) * radius + randomX;
pos[i * 3 + 2] = Math.sin(branch + spin) * radius + randomZ;

Math.pow(Math.random(), 2) biases particles toward the center -- dense core, natural falloff, no manual density zones. The branch + spin formula curves the arms: as radius increases, angle offset wraps particles further around. Real spiral galaxies work on the same principle.

Shared Texture Singleton

Both NebulaParticles (3800 stars) and CelestialGalaxy (2000 particles) need the same soft circular sprite. I was creating it twice -- one CanvasTexture per component, two GPU uploads.

Fixed with a module-level singleton:

tsx
let _circleTexture: THREE.Texture | null = null;

export function getSharedCircleTexture(): THREE.Texture {
  if (!_circleTexture) {
    const canvas = document.createElement("canvas");
    canvas.width = 32; canvas.height = 32;
    const ctx = canvas.getContext("2d")!;
    const gradient = ctx.createRadialGradient(16, 16, 0, 16, 16, 16);
    gradient.addColorStop(0, "rgba(255,255,255,1)");
    gradient.addColorStop(1, "rgba(255,255,255,0)");
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, 32, 32);
    _circleTexture = new THREE.CanvasTexture(canvas);
  }
  return _circleTexture;
}

One 32x32 canvas, one GPU upload, shared everywhere. This is the kind of optimization that matters on mobile.

Making It Run on a Phone

The unoptimized version ran fine on my M1 MacBook and at 8fps on a mid-range Android. Unacceptable. Here's what I cut:

  • Particles: 3800 down to 1000 on mobile. 73% reduction.
  • DPR: Fixed at 1x on mobile instead of [1, 1.5]. Nearly doubles frame rate on high-DPI screens.
  • Bloom: Disabled entirely on mobile. The EffectComposer pipeline is conditionally rendered.
  • Antialiasing: Off on mobile.
  • Visibility: IntersectionObserver pauses the scene when you scroll past the hero. Page Visibility API pauses when the tab is backgrounded. Zero GPU cycles when you're not looking.

Detection is simple: window.innerWidth < 768 || navigator.hardwareConcurrency <= 2. One check on mount, one rendering tier.

Accessibility

A WebGL canvas is inherently inaccessible. Don't try to fix that. Build around it.

  • Canvas wrapper: aria-hidden="true". Screen readers skip it.
  • Skills section further down: standard HTML grid with the same technologies. This is the real content.
  • MotionConfig reducedMotion="user" in providers. All animations respect the system preference.
  • OrbitControls disables auto-rotation when prefers-reduced-motion is set.

The constellation is decoration. The information lives in accessible HTML. This is the only correct pattern for 3D portfolio elements.

Outcome

  • 11 procedural celestial objects
  • 0 texture files, 0 binary assets
  • ~15KB total component code (vs 263KB Spline blob)
  • 3800 particles desktop / 1000 mobile
  • Sub-200ms to first 3D frame (after dynamic import)
  • Runs at 30fps+ on mid-range Android

Every object is deterministic. Same math, same render. No loading spinner, no CDN dependency, no CORS issues, no binary format to debug.

Lessons

Own your dependencies. Spline looked great until I needed to modify it. A tool you can't inspect is a liability, not a feature.

Procedural beats authored for portfolio work. A Blender model probably looks better. But I can explain every vertex in this system. That's the point of a portfolio -- demonstrating understanding, not just output.

Mobile is the constraint that improves everything. The particle reduction, DPR capping, and Bloom removal made the desktop version faster too. Building for the worst case tightens the whole system.

12 useFrame callbacks is 12 too many. I should have consolidated into a single animation loop from the start. It works fine now, but the architecture is wrong. This is the refactor I'll do next.

toneMapped={false} or your Bloom is dead. Two hours of debugging distilled into one prop. Write it on a sticky note.

0
0
0
0
0

Share

Enjoyed this post?

Follow along for more engineering deep dives and project breakdowns.

No spam, ever.

|RSS

Comments

Comments coming soon.

You found the bottom! 🎯