Prism
← All primitives

FlyThrough

Frontier

A free-flight 3D world: pilot a spaceship through an infinite wrap-around field of glowing prismatic crystals — cursor steers, hold to boost. Fog + bloom; the browser as a game engine.

A full mini-experience: mouse/WASD/scroll controls, a target-to-chase score HUD, and a toggleable generative-audio reactor. Render full-screen via next/dynamic ssr:false. The HUD uses Prism's color tokens (text-mist/border-line/bg-obsidian — copy from globals.css or restyle). Honors prefers-reduced-motion.

$npx shadcn@latest add https://prism.icglabs.co/r/fly-through.json
Dependencies:three@react-three/fiber@react-three/postprocessing
View raw manifest →

components/prism/FlyThrough.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { EffectComposer, Bloom } from "@react-three/postprocessing";
import * as THREE from "three";
import { useDeviceTier } from "@/lib/useDeviceTier";
import { AutoDpr, PauseWhenOffscreen } from "@/components/showcase/CanvasPerf";

/**
 * FlyThrough — a free-flight 3D world you pilot like a spaceship through an
 * infinite, wrap-around field of glowing prismatic crystals. Mouse steers
 * (yaw/pitch), W/S or scroll throttles, A/D rolls, hold/Shift boosts. Chase the
 * bright marker for points. Toggle the generative soundtrack and the field pulses
 * to it. Fog hides the wrap; bloom makes it glow.
 *
 * Self-contained; renders its own HUD. Honors prefers-reduced-motion (static field).
 */
const PALETTE = ["#8b5cff", "#5b7cff", "#1fd4e6", "#ff3d81", "#ffb24d", "#9bf25a"];

type Controls = {
  keys: Set<string>;
  throttle: number; // wheel-adjusted base speed
  boost: boolean;
};

function CrystalField({
  count,
  size,
  reduce,
  energy,
}: {
  count: number;
  size: number;
  reduce: boolean;
  energy: React.RefObject<number>;
}) {
  const ref = useRef<THREE.InstancedMesh>(null);
  const light = useRef<THREE.PointLight>(null);
  const dummy = useMemo(() => new THREE.Object3D(), []);
  const camera = useThree((s) => s.camera);

  const data = useMemo(() => {
    const colors = PALETTE.map((c) => new THREE.Color(c));
    return Array.from({ length: count }, () => ({
      base: new THREE.Vector3(
        (Math.random() - 0.5) * size,
        (Math.random() - 0.5) * size,
        (Math.random() - 0.5) * size
      ),
      scale: 0.35 + Math.random() * 1.9,
      color: colors[(Math.random() * colors.length) | 0],
      rot: new THREE.Euler(Math.random() * 6.28, Math.random() * 6.28, Math.random() * 6.28),
      spin: (Math.random() - 0.5) * 0.7,
    }));
  }, [count, size]);

  useEffect(() => {
    const m = ref.current;
    if (!m) return;
    data.forEach((d, i) => m.setColorAt(i, d.color));
    if (m.instanceColor) m.instanceColor.needsUpdate = true;
  }, [data]);

  useFrame((_, dt) => {
    const m = ref.current;
    if (!m) return;
    const cam = camera.position;
    const step = Math.min(dt, 0.05);
    const pulse = 1 + (energy.current ?? 0) * 0.35;
    for (let i = 0; i < data.length; i++) {
      const d = data[i];
      if (!reduce) d.rot.y += d.spin * step;
      const x = d.base.x - cam.x;
      const y = d.base.y - cam.y;
      const z = d.base.z - cam.z;
      dummy.position.set(
        cam.x + (x - size * Math.round(x / size)),
        cam.y + (y - size * Math.round(y / size)),
        cam.z + (z - size * Math.round(z / size))
      );
      dummy.rotation.copy(d.rot);
      dummy.scale.setScalar(d.scale * pulse);
      dummy.updateMatrix();
      m.setMatrixAt(i, dummy.matrix);
    }
    m.instanceMatrix.needsUpdate = true;
    if (light.current) {
      light.current.position.copy(cam);
      light.current.intensity = 22 + (energy.current ?? 0) * 55;
    }
  });

  return (
    <>
      <ambientLight intensity={0.5} />
      <directionalLight position={[3, 5, 2]} intensity={1.0} />
      <pointLight ref={light} intensity={24} distance={62} decay={1.6} color="#9fd0ff" />
      <instancedMesh ref={ref} args={[undefined, undefined, count]}>
        <octahedronGeometry args={[1, 0]} />
        <meshStandardMaterial roughness={0.32} metalness={0.18} />
      </instancedMesh>
    </>
  );
}

function Target({ onReach }: { onReach: () => void }) {
  const ref = useRef<THREE.Mesh>(null);
  const camera = useThree((s) => s.camera);
  const pos = useRef(new THREE.Vector3(0, 0, -45));

  const relocate = useCallback(() => {
    const fwd = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
    const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion);
    const up = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion);
    pos.current
      .copy(camera.position)
      .addScaledVector(fwd, 55 + Math.random() * 45)
      .addScaledVector(right, (Math.random() - 0.5) * 44)
      .addScaledVector(up, (Math.random() - 0.5) * 30);
  }, [camera]);

  useEffect(() => {
    relocate();
  }, [relocate]);

  useFrame((_, dt) => {
    const m = ref.current;
    if (!m) return;
    m.position.copy(pos.current);
    m.rotation.y += dt * 1.2;
    m.rotation.x += dt * 0.7;
    const s = 1 + Math.sin(performance.now() * 0.004) * 0.12;
    m.scale.setScalar(s);
    if (camera.position.distanceTo(pos.current) < 6) {
      onReach();
      relocate();
    }
  });

  return (
    <mesh ref={ref}>
      <icosahedronGeometry args={[2, 0]} />
      <meshStandardMaterial color="#ffffff" emissive="#1fd4e6" emissiveIntensity={2.4} toneMapped={false} />
    </mesh>
  );
}

function Pilot({ reduce, controls }: { reduce: boolean; controls: React.RefObject<Controls> }) {
  const camera = useThree((s) => s.camera) as THREE.PerspectiveCamera;
  const yawV = useRef(0);
  const pitchV = useRef(0);
  const rollV = useRef(0);
  const spd = useRef(16);

  useFrame((state, dt) => {
    if (reduce) return;
    const c = controls.current;
    if (!c) return;
    const step = Math.min(dt, 0.05);
    const px = Math.abs(state.pointer.x) < 0.06 ? 0 : state.pointer.x;
    const py = Math.abs(state.pointer.y) < 0.06 ? 0 : state.pointer.y;
    yawV.current += (-px * 0.7 - yawV.current) * 0.06;
    pitchV.current += (py * 0.5 - pitchV.current) * 0.06;
    const rollInput = (c.keys.has("a") ? 1 : 0) - (c.keys.has("d") ? 1 : 0);
    rollV.current += (rollInput * 1.1 - rollV.current) * 0.08;
    camera.rotateY(yawV.current * step);
    camera.rotateX(pitchV.current * step);
    camera.rotateZ(rollV.current * step);

    // throttle: scroll-wheel base + W/S nudge + boost
    let target = c.throttle;
    if (c.keys.has("w")) target += 16;
    if (c.keys.has("s")) target -= 12;
    if (c.boost || c.keys.has("shift")) target += 26;
    target = THREE.MathUtils.clamp(target, 4, 80);
    spd.current += (target - spd.current) * 0.03;
    camera.translateZ(-spd.current * step);

    const fov = 56 + (spd.current - 16) * 0.45;
    camera.fov = THREE.MathUtils.lerp(camera.fov, THREE.MathUtils.clamp(fov, 50, 92), 0.1);
    camera.updateProjectionMatrix();
  });
  return null;
}

function AudioReactor({ enabled, energy }: { enabled: boolean; energy: React.RefObject<number> }) {
  useEffect(() => {
    if (!enabled) return;
    const Ctx =
      window.AudioContext ||
      (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
    if (!Ctx) return;
    const ctx = new Ctx();
    const master = ctx.createGain();
    master.gain.value = 0.0001;
    master.connect(ctx.destination);
    const analyser = ctx.createAnalyser();
    analyser.fftSize = 256;

    const lp = ctx.createBiquadFilter();
    lp.type = "lowpass";
    lp.frequency.value = 700;
    lp.connect(master);
    lp.connect(analyser);

    const oscs = [110, 164.81, 220, 277.18].map((f, i) => {
      const o = ctx.createOscillator();
      o.type = "sawtooth";
      o.frequency.value = f;
      o.detune.value = (i - 1.5) * 4;
      const g = ctx.createGain();
      g.gain.value = 0.06;
      o.connect(g).connect(lp);
      o.start();
      return o;
    });

    // tremolo LFO so the energy (and visuals) pulse rhythmically
    const lfo = ctx.createOscillator();
    lfo.frequency.value = 1.7;
    const lfoGain = ctx.createGain();
    lfoGain.gain.value = 0.22;
    lfo.connect(lfoGain).connect(master.gain);
    lfo.start();

    master.gain.linearRampToValueAtTime(0.42, ctx.currentTime + 1.6);
    ctx.resume?.();

    let raf = 0;
    const data = new Uint8Array(analyser.frequencyBinCount);
    const tick = () => {
      analyser.getByteFrequencyData(data);
      let s = 0;
      for (let i = 0; i < data.length; i++) s += data[i];
      const v = s / data.length / 255;
      energy.current = (energy.current ?? 0) * 0.7 + v * 0.3;
      raf = requestAnimationFrame(tick);
    };
    tick();

    return () => {
      cancelAnimationFrame(raf);
      oscs.forEach((o) => o.stop());
      lfo.stop();
      ctx.close();
      energy.current = 0;
    };
  }, [enabled, energy]);
  return null;
}

export function FlyThrough({ count = 460, size = 150 }: { count?: number; size?: number }) {
  const [reduce, setReduce] = useState(false);
  const [score, setScore] = useState(0);
  const [sound, setSound] = useState(false);
  const energy = useRef(0);
  const controls = useRef<Controls>({ keys: new Set(), throttle: 16, boost: false });
  const q = useDeviceTier();
  const [low, setLow] = useState(false);
  const n = Math.max(140, Math.round(count * q.scale));
  const bloomOn = q.bloom && !low;

  useEffect(() => {
    setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
    const c = controls.current;
    const down = (e: KeyboardEvent) => c.keys.add(e.key.toLowerCase());
    const up = (e: KeyboardEvent) => c.keys.delete(e.key.toLowerCase());
    const pDown = () => (c.boost = true);
    const pUp = () => (c.boost = false);
    const wheel = (e: WheelEvent) => {
      c.throttle = THREE.MathUtils.clamp(c.throttle - e.deltaY * 0.02, 4, 70);
    };
    window.addEventListener("keydown", down);
    window.addEventListener("keyup", up);
    window.addEventListener("pointerdown", pDown);
    window.addEventListener("pointerup", pUp);
    window.addEventListener("pointercancel", pUp);
    window.addEventListener("wheel", wheel, { passive: true });
    return () => {
      window.removeEventListener("keydown", down);
      window.removeEventListener("keyup", up);
      window.removeEventListener("pointerdown", pDown);
      window.removeEventListener("pointerup", pUp);
      window.removeEventListener("pointercancel", pUp);
      window.removeEventListener("wheel", wheel);
    };
  }, []);

  const reach = useCallback(() => setScore((s) => s + 1), []);

  return (
    <div className="relative h-full w-full" style={{ touchAction: "none" }}>
      <Canvas
        dpr={q.dpr}
        gl={{ antialias: false, powerPreference: "high-performance" }}
        camera={{ position: [0, 0, 0], fov: 56, near: 0.1, far: 200 }}
      >
        <color attach="background" args={["#07070c"]} />
        <fog attach="fog" args={["#07070c", 12, 78]} />
        <CrystalField count={n} size={size} reduce={reduce} energy={energy} />
        <Target onReach={reach} />
        <Pilot reduce={reduce} controls={controls} />
        <AudioReactor enabled={sound} energy={energy} />
        <AutoDpr onLow={() => setLow(true)} />
        <PauseWhenOffscreen />
        {bloomOn && (
          <EffectComposer>
            <Bloom intensity={0.95} luminanceThreshold={0.3} luminanceSmoothing={0.3} mipmapBlur radius={0.7} />
          </EffectComposer>
        )}
      </Canvas>

      {/* HUD */}
      <div className="pointer-events-none absolute inset-0 z-10 flex flex-col justify-between p-6">
        <div className="flex items-start justify-end gap-3">
          <div className="rounded-full border border-line bg-obsidian/50 px-4 py-2 text-sm text-mist backdrop-blur">
            Targets <span className="font-semibold text-cyan">{score}</span>
          </div>
          {!reduce && (
            <button
              onClick={() => setSound((s) => !s)}
              className="pointer-events-auto rounded-full border border-line bg-obsidian/50 px-4 py-2 text-sm text-mist backdrop-blur transition-colors hover:text-ink"
            >
              {sound ? "♪ Sound on" : "♪ Sound off"}
            </button>
          )}
        </div>
        <div className="mx-auto rounded-full border border-line bg-obsidian/50 px-4 py-2 text-center text-xs text-mist backdrop-blur">
          Mouse or drag to steer · W/S or scroll to throttle · A/D roll · hold to boost · chase the marker
        </div>
      </div>
    </div>
  );
}
Live demo — read-only. Every section is a real, copyable primitive.