Warp Field
aadmin2026-09-16 发布3D5 阅读

Warp Field

类型:3D · 分类:Three.js · 收录:2026-09-16 21:09:01 · 源码:会员可见

[hidecontent type="vip1"]
# Warp Field
Nexus’s focused hero warp: 400 emerald additive streaks and 40 luminous tiles streaming through an authored deep-space fog field.

> 来源:ThreeUI Community · https://threeui.com · MIT License(Meng To / Design+Code)
> 组件页:https://threeui.com/component/warp-field · 预览动画:https://threeui.com/previews/warp-field.webm
> 分类:Three.js
> 包含变体(4):streaks、letters、keycaps、hyperspace

/* ===== src/shaders/warp-field/WarpFieldBackground.tsx (tsx) ===== */
import { useEffect, useRef } from "react";
import { createWarpFieldRenderer, WARP_FIELD_DEFAULTS, type WarpFieldOptions } from "./warpFieldRenderer";

export type WarpFieldBackgroundProps = Partial<WarpFieldOptions> & { className?: string };
export function WarpFieldBackground({ className = "", ...props }: WarpFieldBackgroundProps) {
  const hostRef = useRef<HTMLDivElement>(null), canvasRef = useRef<HTMLCanvasElement>(null), optionsRef = useRef({ ...WARP_FIELD_DEFAULTS, ...props }); optionsRef.current = { ...WARP_FIELD_DEFAULTS, ...props };
  useEffect(() => { const host = hostRef.current, canvas = canvasRef.current; if (!host || !canvas) return undefined; const renderer = createWarpFieldRenderer(canvas, () => optionsRef.current); let frame = 0, visible = true; const resize = () => { const bounds = host.getBoundingClientRect(); renderer.resize(bounds.width, bounds.height); renderer.render(); }, tick = () => { renderer.render(); frame = visible && !document.hidden ? requestAnimationFrame(tick) : 0; }; const resizeObserver = new ResizeObserver(resize), intersection = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; if (visible && !frame) frame = requestAnimationFrame(tick); if (!visible && frame) cancelAnimationFrame(frame), frame = 0; }); resizeObserver.observe(host); intersection.observe(host); resize(); frame = requestAnimationFrame(tick); return () => { if (frame) cancelAnimationFrame(frame); resizeObserver.disconnect(); intersection.disconnect(); renderer.dispose(); }; }, []);
  return <div ref={hostRef} className={`threeui-background warp-field${className ? ` ${className}` : ""}`}><canvas ref={canvasRef} style={{ filter: `hue-rotate(${optionsRef.current.hue}deg) saturate(${optionsRef.current.saturation}) brightness(${optionsRef.current.brightness})` }} /></div>;
}

/* ===== src/shaders/warp-field/warpFieldRenderer.ts (typescript) ===== */
import * as THREE from "three128";

export const WARP_FIELD_VARIANTS = ["streaks", "letters", "keycaps", "hyperspace"] as const;
export type WarpFieldVariant = (typeof WARP_FIELD_VARIANTS)[number];

export type WarpFieldOptions = { variant: WarpFieldVariant; speed: number; streakOpacity: number; tileOpacity: number; fov: number; brightness: number; hue: number; saturation: number };
export const WARP_FIELD_DEFAULTS: WarpFieldOptions = { variant: "streaks", speed: 15, streakOpacity: 0.6, tileOpacity: 0.9, fov: 75, brightness: 1, hue: 0, saturation: 1 };

/* every layer streams toward the camera on +z and wraps back once it passes it,
   so the whole family shares the authored warp corridor of the original scene */
const RECYCLE_Z = 200;
const CAP_RECYCLE_Z = 110;
const CAP_RESET_Z = -1200;
/* the glyph and keycap corridors are shorter than the streak corridor: a rigid
   wrap over the full 2000 units arrives in waves at these object counts, and the
   solid ones also have to turn back before they can swallow the lens */
const LETTER_RECYCLE_Z = 140;
const LETTER_RESET_Z = -1300;
const RESET_Z = -1800;
const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const SPEED_SCALE: Record<WarpFieldVariant, number> = { streaks: 1, letters: 0.5, keycaps: 0.7, hyperspace: 2.4 };
const BACKGROUND: Record<WarpFieldVariant, number> = { streaks: 0x02040a, letters: 0x02040a, keycaps: 0x03070c, hyperspace: 0x01020a };

type Layer = { update?: (step: number, time: number) => void; setOpacity?: (streakOpacity: number, tileOpacity: number) => void; dispose: () => void };
type StreakSettings = { count: number; radiusMin: number; radiusSpread: number; lengthMin: number; lengthSpread: number; palette: number[]; opacityScale: number };

function createStreakLayer(group: THREE.Group, settings: StreakSettings, opacity: number): Layer {
  const geometry = new THREE.BufferGeometry();
  const positions = new Float32Array(settings.count * 6);
  const colors = new Float32Array(settings.count * 6);
  const palette = settings.palette.map((hex) => new THREE.Color(hex));
  for (let index = 0; index < settings.count; index += 1) {
    const angle = Math.random() * Math.PI * 2;
    const radius = Math.random() * settings.radiusSpread + settings.radiusMin;
    const x = Math.cos(angle) * radius;
    const y = Math.sin(angle) * radius;
    const z = (Math.random() - 0.5) * 2000;
    const length = Math.random() * settings.lengthSpread + settings.lengthMin;
    positions[index * 6] = x;
    positions[index * 6 + 1] = y;
    positions[index * 6 + 2] = z;
    positions[index * 6 + 3] = x;
    positions[index * 6 + 4] = y;
    positions[index * 6 + 5] = z + length;
    const color = palette[Math.floor(Math.random() * palette.length)];
    colors[index * 6] = color.r; colors[index * 6 + 1] = color.g; colors[index * 6 + 2] = color.b;
    colors[index * 6 + 3] = color.r; colors[index * 6 + 4] = color.g; colors[index * 6 + 5] = color.b;
  }
  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
  const material = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: opacity * settings.opacityScale, blending: THREE.AdditiveBlending });
  const streaks = new THREE.LineSegments(geometry, material);
  group.add(streaks);
  const attribute = geometry.attributes.position as unknown as { array: Float32Array; needsUpdate: boolean };
  return {
    update(step) {
      for (let index = 0; index < settings.count; index += 1) {
        positions[index * 6 + 2] += step;
        positions[index * 6 + 5] += step;
        if (positions[index * 6 + 2] > RECYCLE_Z) {
          const length = positions[index * 6 + 5] - positions[index * 6 + 2];
          positions[index * 6 + 2] = RESET_Z;
          positions[index * 6 + 5] = RESET_Z + length;
        }
      }
      attribute.needsUpdate = true;
    },
    setOpacity(streakOpacity) {
      const scaled = streakOpacity * settings.opacityScale;
      if (material.opacity !== scaled) material.opacity = scaled;
    },
    dispose() { geometry.dispose(); material.dispose(); },
  };
}

/* the authored 40 luminous tiles of the original Nexus hero */
function createTileLayer(group: THREE.Group, opacity: number): Layer {
  const geometry = new THREE.PlaneGeometry(8, 20);
  const template = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity, side: THREE.DoubleSide });
  const tiles: THREE.Mesh[] = [];
  let lastOpacity = opacity;
  for (let index = 0; index < 40; index += 1) {
    const material = template.clone();
    material.color.setHex(Math.random() > 0.6 ? 0xa7f3d0 : Math.random() > 0.5 ? 0xd1fae5 : 0xffffff);
    const mesh = new THREE.Mesh(geometry, material);
    const angle = Math.random() * Math.PI * 2;
    const radius = Math.random() * 400 + 100;
    mesh.position.x = Math.cos(angle) * radius;
    mesh.position.y = Math.sin(angle) * radius;
    mesh.position.z = (Math.random() - 0.5) * 2000;
    mesh.lookAt(0, 0, mesh.position.z + 100);
    const scale = Math.random() * 1.5 + 0.5;
    mesh.scale.set(scale, scale, scale);
    group.add(mesh);
    tiles.push(mesh);
  }
  return {
    update(step) {
      tiles.forEach((tile) => {
        tile.position.z += step;
        if (tile.position.z > RECYCLE_Z) tile.position.z = RESET_Z;
      });
    },
    setOpacity(_streakOpacity, tileOpacity) {
      if (lastOpacity === tileOpacity) return;
      tiles.forEach((tile) => { (tile.material as THREE.MeshBasicMaterial).opacity = tileOpacity; });
      lastOpacity = tileOpacity;
    },
    dispose() {
      geometry.dispose();
      template.dispose();
      tiles.forEach((tile) => (tile.material as THREE.MeshBasicMaterial).dispose());
    },
  };
}

type GlyphAtlas = { texture: THREE.CanvasTexture; columns: number; rows: number };

function createGlyphAtlas(color: string): GlyphAtlas {
  const columns = 6;
  const rows = 6;
  const cell = 128;
  const canvas = document.createElement("canvas");
  canvas.width = columns * cell;
  canvas.height = rows * cell;
  const context = canvas.getContext("2d");
  if (context) {
    context.fillStyle = color;
    context.font = `700 ${Math.round(cell * 0.68)}px ui-monospace, "SF Mono", Menlo, Consolas, monospace`;
    context.textAlign = "center";
    context.textBaseline = "middle";
    GLYPHS.split("").forEach((glyph, index) => {
      const column = index % columns;
      const row = Math.floor(index / columns);
      context.fillText(glyph, column * cell + cell / 2, row * cell + cell * 0.54);
    });
  }
  const texture = new THREE.CanvasTexture(canvas);
  texture.minFilter = THREE.LinearFilter;
  return { texture, columns, rows };
}

/* one unit plane per glyph, with its uv window pinned to that atlas cell, so a
   whole alphabet renders from a single texture and a handful of materials */
function createGlyphGeometries(atlas: GlyphAtlas): THREE.PlaneGeometry[] {
  return GLYPHS.split("").map((_, index) => {
    const geometry = new THREE.PlaneGeometry(1, 1);
    const column = index % atlas.columns;
    const row = Math.floor(index / atlas.columns);
    const originU = column / atlas.columns;
    const originV = 1 - (row + 1) / atlas.rows;
    const uv = geometry.attributes.uv as THREE.BufferAttribute;
    for (let vertex = 0; vertex < uv.count; vertex += 1) {
      uv.setXY(vertex, originU + uv.getX(vertex) / atlas.columns, originV + uv.getY(vertex) / atlas.rows);
    }
    uv.needsUpdate = true;
    return geometry;
  });
}

type Tumbler = { mesh: THREE.Mesh; spin: number; swayX: number; swayY: number; phase: number; drift: number; radius: number };

function createLetterLayer(group: THREE.Group, opacity: number): Layer {
  const atlas = createGlyphAtlas("#ffffff");
  const geometries = createGlyphGeometries(atlas);
  const tints = [0xffffff, 0xa7f3d0, 0x34d399].map((color) => new THREE.MeshBasicMaterial({
    map: atlas.texture, color, transparent: true, opacity, depthWrite: false, side: THREE.DoubleSide,
  }));
  const letters: Tumbler[] = [];
  let lastOpacity = opacity;
  for (let index = 0; index < 260; index += 1) {
    const mesh = new THREE.Mesh(geometries[Math.floor(Math.random() * geometries.length)], tints[Math.floor(Math.random() * tints.length)]);
    const angle = Math.random() * Math.PI * 2;
    const radius = Math.random() * 430 + 60;
    mesh.position.set(Math.cos(angle) * radius, Math.sin(angle) * radius, LETTER_RESET_Z + Math.random() * (LETTER_RECYCLE_Z - LETTER_RESET_Z));
    const scale = Math.random() * 30 + 24;
    mesh.scale.set(scale, scale, scale);
    group.add(mesh);
    letters.push({ mesh, spin: (Math.random() - 0.5) * 0.02, swayX: Math.random() * 0.5 + 0.2, swayY: Math.random() * 0.6 + 0.2, phase: Math.random() * Math.PI * 2, drift: Math.random() * 0.9 + 0.2, radius });
  }
  return {
    update(step, time) {
      letters.forEach((letter) => {
        const { mesh } = letter;
        mesh.position.z += step;
        /* letters wander across the corridor instead of running the rails, which
           is what separates them from the streaks flying past behind them */
        const wander = time * letter.drift + letter.phase;
        mesh.position.x += Math.cos(wander) * letter.drift * 0.9;
        mesh.position.y += Math.sin(wander * 0.8) * letter.drift * 0.9;
        mesh.rotation.z += letter.spin;
        mesh.rotation.x = Math.sin(wander * 0.6) * letter.swayX;
        mesh.rotation.y = Math.cos(wander * 0.5) * letter.swayY;
        if (mesh.position.z > LETTER_RECYCLE_Z) {
          const angle = Math.random() * Math.PI * 2;
          const radius = Math.random() * 430 + 60;
          mesh.position.set(Math.cos(angle) * radius, Math.sin(angle) * radius, LETTER_RESET_Z);
        }
      });
    },
    setOpacity(_streakOpacity, tileOpacity) {
      if (lastOpacity === tileOpacity) return;
      tints.forEach((tint) => { tint.opacity = tileOpacity; });
      lastOpacity = tileOpacity;
    },
    dispose() {
      geometries.forEach((geometry) => geometry.dispose());
      tints.forEach((tint) => tint.dispose());
      atlas.texture.dispose();
    },
  };
}

/* a box with its top face drawn in, the cheapest read of an injection-moulded cap */
function createKeycapGeometry(width: number, height: number, depth: number): THREE.BoxGeometry {
  const geometry = new THREE.BoxGeometry(width, height, depth);
  const position = geometry.attributes.position as THREE.BufferAttribute;
  for (let vertex = 0; vertex < position.count; vertex += 1) {
    if (position.getY(vertex) > 0) position.setXYZ(vertex, position.getX(vertex) * 0.78, position.getY(vertex), position.getZ(vertex) * 0.78);
  }
  position.needsUpdate = true;
  geometry.computeVertexNormals();
  return geometry;
}

function createSparkTexture(): THREE.CanvasTexture {
  const size = 64;
  const canvas = document.createElement("canvas");
  canvas.width = size;
  canvas.height = size;
  const context = canvas.getContext("2d");
  if (context) {
    const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
    gradient.addColorStop(0, "rgba(255,255,255,1)");
    gradient.addColorStop(0.4, "rgba(255,255,255,0.5)");
    gradient.addColorStop(1, "rgba(255,255,255,0)");
    context.fillStyle = gradient;
    context.fillRect(0, 0, size, size);
  }
  return new THREE.CanvasTexture(canvas);
}

function createSparkLayer(group: THREE.Group, count: number, size: number, color: number, opacity: number, radiusSpread: number) {
  const geometry = new THREE.BufferGeometry();
  const positions = new Float32Array(count * 3);
  for (let index = 0; index < count; index += 1) {
    const angle = Math.random() * Math.PI * 2;
    const radius = Math.random() * radiusSpread + 20;
    positions[index * 3] = Math.cos(angle) * radius;
    positions[index * 3 + 1] = Math.sin(angle) * radius;
    positions[index * 3 + 2] = (Math.random() - 0.5) * 2000;
  }
  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  const texture = createSparkTexture();
  const material = new THREE.PointsMaterial({ map: texture, color, size, transparent: true, opacity, blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true });
  const points = new THREE.Points(geometry, material);
  group.add(points);
  const attribute = geometry.attributes.position as unknown as { needsUpdate: boolean };
  return {
    positions, count, material,
    update(step: number) {
      for (let index = 0; index < count; index += 1) {
        positions[index * 3 + 2] += step;
        if (positions[index * 3 + 2] > RECYCLE_Z) positions[index * 3 + 2] = RESET_Z;
      }
      attribute.needsUpdate = true;
    },
    dispose() { geometry.dispose(); material.dispose(); texture.dispose(); },
  };
}

function createKeycapLayer(scene: THREE.Scene, group: THREE.Group, opacity: number): Layer {
  const atlas = createGlyphAtlas("#ffffff");
  const legendGeometries = createGlyphGeometries(atlas);
  const capGeometry = createKeycapGeometry(26, 14, 26);
  const bodies = [0x3d4844, 0x505c57, 0x2b3431].map((color) => new THREE.MeshLambertMaterial({ color, emissive: 0x03110b, transparent: true, opacity }));
  const legends = [0x9df5cf, 0xffffff].map((color) => new THREE.MeshBasicMaterial({
    map: atlas.texture, color, transparent: true, opacity, depthWrite: false, side: THREE.DoubleSide,
  }));
  const ambient = new THREE.AmbientLight(0x0f1a17, 1);
  const key = new THREE.DirectionalLight(0xf4fffb, 1.9);
  key.position.set(0.4, 1, 0.7);
  const rim = new THREE.DirectionalLight(0x34d399, 0.45);
  rim.position.set(-0.7, -0.4, 0.5);
  const flash = new THREE.PointLight(0x10b981, 0.8, 900);
  flash.position.set(0, 0, 140);
  scene.add(ambient, key, rim, flash);
  const caps: Tumbler[] = [];
  let lastOpacity = opacity;
  for (let index = 0; index < 95; index += 1) {
    const mesh = new THREE.Mesh(capGeometry, bodies[Math.floor(Math.random() * bodies.length)]);
    const legend = new THREE.Mesh(legendGeometries[Math.floor(Math.random() * legendGeometries.length)], legends[Math.floor(Math.random() * legends.length)]);
    legend.scale.set(15, 15, 15);
    legend.position.y = 7.2;
    legend.rotation.x = -Math.PI / 2;
    mesh.add(legend);
    const angle = Math.random() * Math.PI * 2;
    const radius = Math.random() * 430 + 130;
    mesh.position.set(Math.cos(angle) * radius, Math.sin(angle) * radius, CAP_RESET_Z + Math.random() * (CAP_RECYCLE_Z - CAP_RESET_Z));
    mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
    const scale = Math.random() * 1.15 + 0.8;
    mesh.scale.set(scale, scale, scale);
    group.add(mesh);
    caps.push({ mesh, spin: (Math.random() - 0.5) * 0.03, swayX: (Math.random() - 0.5) * 0.026, swayY: (Math.random() - 0.5) * 0.03, phase: 0, drift: 0, radius });
  }
  const sparks = createSparkLayer(group, 750, 7, 0x6ee7b7, opacity, 620);
  return {
    update(step) {
      caps.forEach((cap) => {
        cap.mesh.position.z += step;
        cap.mesh.rotation.x += cap.swayX;
        cap.mesh.rotation.y += cap.swayY;
        cap.mesh.rotation.z += cap.spin;
        /* a cap is a solid volume, so it wraps before it can swallow the lens */
        if (cap.mesh.position.z > CAP_RECYCLE_Z) {
          const angle = Math.random() * Math.PI * 2;
          const radius = Math.random() * 430 + 130;
          cap.mesh.position.set(Math.cos(angle) * radius, Math.sin(angle) * radius, CAP_RESET_Z);
        }
      });
      sparks.update(step * 1.35);
    },
    setOpacity(streakOpacity, tileOpacity) {
      if (sparks.material.opacity !== streakOpacity) sparks.material.opacity = streakOpacity;
      if (lastOpacity === tileOpacity) return;
      bodies.forEach((body) => { body.opacity = tileOpacity; });
      legends.forEach((legend) => { legend.opacity = tileOpacity; });
      lastOpacity = tileOpacity;
    },
    dispose() {
      capGeometry.dispose();
      legendGeometries.forEach((geometry) => geometry.dispose());
      bodies.forEach((body) => body.dispose());
      legends.forEach((legend) => legend.dispose());
      atlas.texture.dispose();
      sparks.dispose();
      scene.remove(ambient, key, rim, flash);
    },
  };
}

function createTunnelTexture(): THREE.CanvasTexture {
  const size = 512;
  const canvas = document.createElement("canvas");
  canvas.width = size;
  canvas.height = size;
  const context = canvas.getContext("2d");
  if (context) {
    context.fillStyle = "#000000";
    context.fillRect(0, 0, size, size);
    for (let index = 0; index < 240; index += 1) {
      const x = Math.random() * size;
      const width = Math.random() * 3 + 0.6;
      const height = Math.random() * 320 + 90;
      const top = Math.random() * size;
      const alpha = (Math.random() * 0.45 + 0.08).toFixed(3);
      /* drawn once above and once below the seam so the scrolling wrap is invisible */
      for (const offset of [-size, 0, size]) {
        const gradient = context.createLinearGradient(0, top + offset, 0, top + offset + height);
        gradient.addColorStop(0, "rgba(191,219,254,0)");
        gradient.addColorStop(0.5, `rgba(224,238,255,${alpha})`);
        gradient.addColorStop(1, "rgba(147,197,253,0)");
        context.fillStyle = gradient;
        context.fillRect(x, top + offset, width, height);
      }
    }
  }
  const texture = new THREE.CanvasTexture(canvas);
  texture.wrapS = THREE.RepeatWrapping;
  texture.wrapT = THREE.RepeatWrapping;
  texture.repeat.set(4, 2);
  return texture;
}

function createGlowTexture(): THREE.CanvasTexture {
  const size = 256;
  const canvas = document.createElement("canvas");
  canvas.width = size;
  canvas.height = size;
  const context = canvas.getContext("2d");
  if (context) {
    const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
    gradient.addColorStop(0, "rgba(255,255,255,1)");
    gradient.addColorStop(0.18, "rgba(219,234,254,0.55)");
    gradient.addColorStop(0.45, "rgba(96,165,250,0.16)");
    gradient.addColorStop(1, "rgba(2,6,23,0)");
    context.fillStyle = gradient;
    context.fillRect(0, 0, size, size);
  }
  return new THREE.CanvasTexture(canvas);
}

function createHyperspaceLayer(group: THREE.Group, opacity: number): Layer {
  const tunnelTexture = createTunnelTexture();
  const tunnelGeometry = new THREE.CylinderGeometry(900, 240, 3000, 64, 1, true);
  tunnelGeometry.rotateX(Math.PI / 2);
  const tunnelMaterial = new THREE.MeshBasicMaterial({ map: tunnelTexture, side: THREE.BackSide, transparent: true, opacity: opacity * 0.6, blending: THREE.AdditiveBlending, depthWrite: false });
  const tunnel = new THREE.Mesh(tunnelGeometry, tunnelMaterial);
  tunnel.position.z = -1400;
  group.add(tunnel);

  const glowTexture = createGlowTexture();
  const glowMaterial = new THREE.SpriteMaterial({ map: glowTexture, transparent: true, opacity, blending: THREE.AdditiveBlending, depthWrite: false });
  const glow = new THREE.Sprite(glowMaterial);
  glow.position.z = -900;
  glow.scale.set(760, 760, 1);
  group.add(glow);
  let lastOpacity = opacity;
  return {
    update(step, time) {
      /* the walls run with the streaks, and a slow roll keeps the jump from
         reading as a still image once the streak rails line up */
      tunnelTexture.offset.y -= step * 0.0016;
      tunnel.rotation.z += 0.0016;
      const pulse = 1 + Math.sin(time * 1.6) * 0.06;
      glow.scale.set(760 * pulse, 760 * pulse, 1);
    },
    setOpacity(_streakOpacity, tileOpacity) {
      if (lastOpacity === tileOpacity) return;
      tunnelMaterial.opacity = tileOpacity * 0.6;
      glowMaterial.opacity = tileOpacity;
      lastOpacity = tileOpacity;
    },
    dispose() {
      tunnelGeometry.dispose();
      tunnelMaterial.dispose();
      tunnelTexture.dispose();
      glowMaterial.dispose();
      glowTexture.dispose();
    },
  };
}

const STREAK_SETTINGS: Record<WarpFieldVariant, StreakSettings> = {
  streaks: { count: 400, radiusMin: 20, radiusSpread: 800, lengthMin: 50, lengthSpread: 150, palette: [0x10b981, 0x059669, 0x34d399, 0xffffff], opacityScale: 1 },
  letters: { count: 260, radiusMin: 20, radiusSpread: 800, lengthMin: 40, lengthSpread: 120, palette: [0x10b981, 0x059669, 0x34d399, 0xffffff], opacityScale: 1 },
  keycaps: { count: 220, radiusMin: 20, radiusSpread: 800, lengthMin: 40, lengthSpread: 140, palette: [0x10b981, 0x34d399, 0xa7f3d0, 0xffffff], opacityScale: 1 },
  hyperspace: { count: 1200, radiusMin: 6, radiusSpread: 760, lengthMin: 170, lengthSpread: 420, palette: [0xffffff, 0xdbeafe, 0x93c5fd, 0x60a5fa, 0xc7d2fe], opacityScale: 1.45 },
};

export function createWarpFieldRenderer(canvas: HTMLCanvasElement, getOptions: () => WarpFieldOptions) {
  const startOptions = getOptions();
  const variant: WarpFieldVariant = WARP_FIELD_VARIANTS.includes(startOptions.variant) ? startOptions.variant : WARP_FIELD_DEFAULTS.variant;
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(BACKGROUND[variant]);
  scene.fog = new THREE.FogExp2(BACKGROUND[variant], 0.001);
  const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 2000);
  camera.position.z = 0;
  const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: "high-performance" });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));

  const group = new THREE.Group();
  scene.add(group);

  const layers: Layer[] = [createStreakLayer(group, STREAK_SETTINGS[variant], startOptions.streakOpacity)];
  if (variant === "streaks") layers.push(createTileLayer(group, startOptions.tileOpacity));
  if (variant === "letters") layers.push(createLetterLayer(group, startOptions.tileOpacity));
  if (variant === "keycaps") layers.push(createKeycapLayer(scene, group, startOptions.tileOpacity));
  if (variant === "hyperspace") layers.push(createHyperspaceLayer(group, startOptions.tileOpacity));

  let elapsed = 0;
  return {
    resize(width: number, height: number) {
      camera.aspect = width / Math.max(1, height);
      camera.updateProjectionMatrix();
      renderer.setSize(width, height, false);
    },
    render() {
      const options = getOptions();
      if (camera.fov !== options.fov) {
        camera.fov = options.fov;
        camera.updateProjectionMatrix();
      }
      elapsed += 1 / 60;
      const step = options.speed * SPEED_SCALE[variant];
      layers.forEach((layer) => {
        layer.setOpacity?.(options.streakOpacity, options.tileOpacity);
        layer.update?.(step, elapsed);
      });
      renderer.render(scene, camera);
    },
    dispose() {
      layers.forEach((layer) => layer.dispose());
      renderer.dispose();
    },
  };
}
[/hidecontent]
← 返回首页