title: 宇宙环球
description: A breathtaking, animated 3D portal built with Three.js that features glowing rings, floating crystals, space distortion, and interdimensional streams.
---

```vue
<template>
  <div class="h-[28rem] w-full flex flex-col overflow-clip gap-4">
    <CosmicPortal
      ref="cosmicPortal"
      container-class="rounded-lg"
    />
    <div class="flex flex-row items-center justify-center gap-4">
      <UiButton @click="activatePortal"> Activate Portal </UiButton>
      <UiButton @click="shiftDimensions"> Shift Dimensions </UiButton>
    </div>
  </div>
</template>

<script lang="ts" setup>
const cosmicPortal = useTemplateRef("cosmicPortal");

function activatePortal() {
  if (!cosmicPortal.value) return;
  cosmicPortal.value.activatePortal();
}

function shiftDimensions() {
  if (!cosmicPortal.value) return;
  cosmicPortal.value.shiftDimensions();
}
</script>

```

## API

| Prop 名称          | 类型     | 默认值    | 描述                                                        |
| ------------------ | -------- | --------- | ----------------------------------------------------------- |
| `portalComplexity` | `number` | `4`       | Controls the complexity of the portal effects and geometry. |
| `crystalCount`     | `number` | `12`      | Number of floating crystals in the scene.                   |
| `primaryColor`     | `string` | `#9b59b6` | Main color for portal and background glow.                  |
| `secondaryColor`   | `string` | `#3498db` | Secondary color for blending and glow.                      |
| `accentColor`      | `string` | `#e74c3c` | Color used for portal energy and highlight.                 |
| `vortexColor`      | `string` | `#2ecc71` | Color used for swirling vortex and dimensional streams.     |
| `rotationSpeed`    | `number` | `0.3`     | Speed at which objects rotate.                              |
| `bloomStrength`    | `number` | `1.2`     | Intensity of bloom postprocessing.                          |
| `bloomRadius`      | `number` | `0.7`     | Radius of the bloom effect.                                 |
| `bloomThreshold`   | `number` | `0.2`     | Threshold for bloom visibility.                             |
| `dimensionShift`   | `number` | `4`       | Level of dimension distortion for shader animation.         |

## 功能特性

- **Stunning Visuals**: Animated cosmic background, glowing vortex rings, and interdimensional streams.
- **Shader Driven Effects**: Unique portal burst, distortion, and pulsing animations.
- **User Controls**: Zoom, rotate, and orbit around the scene using OrbitControls.
- **Postprocessing**: Includes bloom and FXAA for cinematic quality.
- **Dynamic Colors**: Easily shift dimensions and regenerate colors using exposed methods.
- **Exposed Actions**: Call `activatePortal()` to trigger portal effects or `shiftDimensions()` to regenerate the visual style.

## 安装

确保在您的项目中安装了以下库：

:pm-install{name="three postprocessing"}
:pm-install{name="@types/three" save-dev}

## 通过 CLI 安装

```vue
<InstallationCli component-id="cosmic-portal" />
```

## 手动安装

复制和粘贴下列代码：

```vue
<template>
  <div :class="['relative overflow-hidden h-full w-full', props.containerClass]">
    <canvas
      ref="canvasRef"
      :class="['absolute inset-0 h-full w-full block', props.class]"
    />
  </div>
</template>

<script setup lang="ts">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { FXAAShader } from "three/addons/shaders/FXAAShader.js";

interface PortalParams {
  portalComplexity: number;
  crystalCount: number;
  primaryColor: string;
  secondaryColor: string;
  accentColor: string;
  vortexColor: string;
  rotationSpeed: number;
  bloomStrength: number;
  bloomRadius: number;
  bloomThreshold: number;
  dimensionShift: number;
}

const props = withDefaults(
  defineProps<Partial<PortalParams> & { class?: string; containerClass?: string }>(),
  {
    portalComplexity: 4,
    crystalCount: 12,
    primaryColor: "#9b59b6",
    secondaryColor: "#3498db",
    accentColor: "#e74c3c",
    vortexColor: "#2ecc71",
    rotationSpeed: 0.3,
    bloomStrength: 1.2,
    bloomRadius: 0.7,
    bloomThreshold: 0.2,
    dimensionShift: 4,
    class: "",
    containerClass: "",
  },
);

const params = ref<PortalParams>({
  portalComplexity: props.portalComplexity,
  crystalCount: props.crystalCount,
  primaryColor: props.primaryColor,
  secondaryColor: props.secondaryColor,
  accentColor: props.accentColor,
  vortexColor: props.vortexColor,
  rotationSpeed: props.rotationSpeed,
  bloomStrength: props.bloomStrength,
  bloomRadius: props.bloomRadius,
  bloomThreshold: props.bloomThreshold,
  dimensionShift: props.dimensionShift,
});

const canvasRef = ref<HTMLCanvasElement>();

// Three.js core objects
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let composer: EffectComposer;
let controls: OrbitControls;
let bloomPass: UnrealBloomPass;
let fxaaPass: ShaderPass;
let clock: THREE.Clock;

// Portal objects
let meshes: THREE.Object3D[] = [];
let materials: THREE.Material[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let portalMaterials: any[] = [];
let portalLights: THREE.Light[] = [];
let animationId: number;
let time = 0;

let resizeObserver: ResizeObserver;

function initThreeJS() {
  if (!canvasRef.value) return;

  // Scene setup
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0x0a0015);
  scene.fog = new THREE.FogExp2(0x1a0033, 0.001);

  // Camera setup - get container dimensions first
  const container = canvasRef.value.parentElement;
  const width = container ? container.clientWidth : window.innerWidth;
  const height = container ? container.clientHeight : window.innerHeight;

  camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
  camera.position.set(0, 0, 15);

  // Lighting
  scene.add(new THREE.AmbientLight(0x330066, 0.2));
  const mainLight = new THREE.DirectionalLight(0xffffff, 0.6);
  mainLight.position.set(10, 10, 5);
  scene.add(mainLight);

  // Portal lights
  const lightColors = [
    params.value.primaryColor,
    params.value.secondaryColor,
    params.value.accentColor,
    params.value.vortexColor,
  ];
  for (let i = 0; i < 6; i++) {
    const light = new THREE.PointLight(new THREE.Color(lightColors[i % 4]), 0.8, 20);
    scene.add(light);
    portalLights.push(light);
  }

  // Renderer setup - use the container dimensions
  renderer = new THREE.WebGLRenderer({
    canvas: canvasRef.value,
    antialias: true,
    powerPreference: "high-performance",
  });
  renderer.setSize(width, height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.2;

  // Controls
  controls = new OrbitControls(camera, canvasRef.value);
  controls.enableDamping = true;
  controls.dampingFactor = 0.08;
  controls.autoRotate = true;
  controls.autoRotateSpeed = 0.5;
  controls.minDistance = 8;
  controls.maxDistance = 40;

  // Post-processing
  composer = new EffectComposer(renderer);
  composer.addPass(new RenderPass(scene, camera));

  bloomPass = new UnrealBloomPass(
    new THREE.Vector2(width, height),
    params.value.bloomStrength,
    params.value.bloomRadius,
    params.value.bloomThreshold,
  );
  composer.addPass(bloomPass);

  fxaaPass = new ShaderPass(FXAAShader);
  const pixelRatio = renderer.getPixelRatio();
  fxaaPass.material.uniforms["resolution"].value.set(
    1 / (width * pixelRatio),
    1 / (height * pixelRatio),
  );
  composer.addPass(fxaaPass);

  clock = new THREE.Clock();
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addPortalShader(material: any) {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  material.onBeforeCompile = (shader: any) => {
    shader.uniforms.time = { value: 0 };
    shader.uniforms.pulseTime = { value: -1000 };
    shader.uniforms.portalSpeed = { value: 8.0 };
    shader.uniforms.portalColor = { value: new THREE.Color(params.value.accentColor) };
    shader.uniforms.dimensionShift = { value: 0 };

    shader.vertexShader = `varying vec3 vWorldPosition;\n` + shader.vertexShader;

    shader.fragmentShader =
      `
        uniform float time;
        uniform float pulseTime;
        uniform float portalSpeed;
        uniform vec3 portalColor;
        uniform float dimensionShift;
        varying vec3 vWorldPosition;\n` + shader.fragmentShader;

    shader.vertexShader = shader.vertexShader.replace(
      "#include <begin_vertex>",
      `#include <begin_vertex>
       vWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;`,
    );

    shader.fragmentShader = shader.fragmentShader.replace(
      "#include <emissivemap_fragment>",
      `#include <emissivemap_fragment>
       float timeSincePortal = time - pulseTime;
       if(timeSincePortal > 0.0 && timeSincePortal < 3.0) {
         float portalRadius = timeSincePortal * portalSpeed;
         float currentRadius = length(vWorldPosition);
         float portalWidth = 1.5;
         float portalEffect = smoothstep(portalRadius - portalWidth, portalRadius, currentRadius) -
                             smoothstep(portalRadius, portalRadius + portalWidth, currentRadius);
         vec3 dimensionalColor = mix(portalColor, vec3(1.0, 0.5, 1.0), sin(dimensionShift * 3.14159) * 0.5 + 0.5);
         totalEmissiveRadiance += dimensionalColor * portalEffect * 4.0;
       }`,
    );
    portalMaterials.push(shader);
  };
}

function createCosmicBackground() {
  const count = 4000;
  const geo = new THREE.BufferGeometry();
  const positions = new Float32Array(count * 3);
  const colors = new Float32Array(count * 3);

  for (let i = 0; i < count; i++) {
    const i3 = i * 3;
    const radius = 80 + Math.random() * 50;
    const theta = Math.random() * Math.PI * 2;
    const phi = Math.acos(2 * Math.random() - 1);

    positions[i3] = radius * Math.sin(phi) * Math.cos(theta);
    positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
    positions[i3 + 2] = radius * Math.cos(phi);

    const temp = Math.random();
    const color = new THREE.Color();
    if (temp < 0.15) color.setHSL(0.8, 0.8, 0.9);
    else if (temp < 0.4) color.setHSL(0.6, 0.6, 0.8);
    else if (temp < 0.7) color.setHSL(0.1, 0.3, 0.9);
    else color.setHSL(0.3, 0.7, 0.6);

    color.toArray(colors, i3);
  }

  geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  geo.setAttribute("color", new THREE.BufferAttribute(colors, 3));

  const mat = new THREE.PointsMaterial({
    size: 0.3,
    vertexColors: true,
    sizeAttenuation: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
    transparent: true,
  });

  const stars = new THREE.Points(geo, mat);
  scene.add(stars);
  meshes.push(stars);
  materials.push(mat);
}

function createPortalCore() {
  const geo = new THREE.SphereGeometry(0.8, 32, 32);
  const mat = new THREE.ShaderMaterial({
    uniforms: {
      time: { value: 0 },
      pulseTime: { value: -1000 },
      dimensionShift: { value: 0 },
      color1: { value: new THREE.Color(params.value.primaryColor) },
      color2: { value: new THREE.Color(params.value.secondaryColor) },
      color3: { value: new THREE.Color(params.value.accentColor) },
    },
    vertexShader: `
      uniform float time;
      uniform float dimensionShift;
      varying vec3 vPos;
      varying vec3 vNorm;
      void main() {
        vPos = position;
        vNorm = normal;
        float warp = sin(position.x * 10.0 + time * 3.0) * 0.1;
        float shift = sin(dimensionShift * 6.28318) * 0.3;
        vec3 p = position * (1.0 + warp + shift);
        gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
      }
    `,
    fragmentShader: `
      uniform float time;
      uniform float pulseTime;
      uniform float dimensionShift;
      uniform vec3 color1;
      uniform vec3 color2;
      uniform vec3 color3;
      varying vec3 vPos;
      varying vec3 vNorm;
      void main() {
        float noise = sin(vPos.x * 20.0 + time * 4.0) * cos(vPos.z * 15.0 + time * 3.0);
        vec3 baseColor = mix(color1, color2, 0.5 + 0.5 * sin(time * 2.0 + dimensionShift));
        vec3 finalColor = mix(baseColor, color3, noise * 0.3);

        float fresnel = pow(1.0 - abs(dot(vNorm, normalize(cameraPosition - vPos))), 3.0);
        finalColor = mix(finalColor, vec3(1.0), fresnel * 0.5);

        float timeSincePortal = time - pulseTime;
        if(timeSincePortal > 0.0 && timeSincePortal < 1.0) {
          float burst = 1.0 - timeSincePortal;
          finalColor += vec3(1.0) * burst * 3.0;
        }

        gl_FragColor = vec4(finalColor, 0.9);
      }
    `,
    transparent: true,
  });

  portalMaterials.push(mat);
  const mesh = new THREE.Mesh(geo, mat);
  scene.add(mesh);
  meshes.push(mesh);
}

function createVortexRings() {
  const colors = [
    params.value.primaryColor,
    params.value.secondaryColor,
    params.value.accentColor,
    params.value.vortexColor,
  ];

  for (let ring = 0; ring < 5; ring++) {
    const radius = 2 + ring * 0.8;
    const geo = new THREE.TorusGeometry(radius, 0.05, 16, 64);
    const mat = new THREE.MeshPhysicalMaterial({
      color: new THREE.Color(colors[ring % colors.length]),
      transparent: true,
      opacity: 0.7,
      metalness: 0.8,
      roughness: 0.2,
      clearcoat: 0.8,
      clearcoatRoughness: 0.1,
      emissive: new THREE.Color(colors[ring % colors.length]).multiplyScalar(0.2),
    });

    addPortalShader(mat);
    const mesh = new THREE.Mesh(geo, mat);
    mesh.rotation.x = Math.PI * 0.1 * ring;
    mesh.rotation.z = Math.PI * 0.15 * ring;
    scene.add(mesh);
    meshes.push(mesh);
  }
}

function createFloatingCrystals() {
  const colors = [
    params.value.accentColor,
    params.value.vortexColor,
    params.value.primaryColor,
    params.value.secondaryColor,
  ];

  for (let i = 0; i < params.value.crystalCount; i++) {
    const geo = new THREE.OctahedronGeometry(0.3 + Math.random() * 0.4, 1);
    const mat = new THREE.MeshPhysicalMaterial({
      color: new THREE.Color(colors[i % colors.length]),
      transparent: true,
      opacity: 0.8,
      metalness: 0.9,
      roughness: 0.1,
      clearcoat: 1.0,
      clearcoatRoughness: 0.0,
      emissive: new THREE.Color(colors[i % colors.length]).multiplyScalar(0.3),
    });

    addPortalShader(mat);
    const mesh = new THREE.Mesh(geo, mat);
    const angle = (i / params.value.crystalCount) * Math.PI * 2;
    const radius = 6 + Math.random() * 4;
    mesh.position.set(
      Math.cos(angle) * radius,
      (Math.random() - 0.5) * 8,
      Math.sin(angle) * radius,
    );
    mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
    scene.add(mesh);
    meshes.push(mesh);
  }
}

function createDimensionalStreams() {
  const colors = [params.value.vortexColor, params.value.primaryColor, params.value.secondaryColor];

  for (let i = 0; i < 8; i++) {
    const points = [];
    const segments = 120;

    for (let j = 0; j <= segments; j++) {
      const t = j / segments;
      const angle = t * Math.PI * 12 + i * Math.PI * 0.25;
      const radius = 3 + Math.sin(t * Math.PI * 6) * 1.5;
      const height = (t - 0.5) * 15;

      points.push(new THREE.Vector3(Math.cos(angle) * radius, height, Math.sin(angle) * radius));
    }

    const curve = new THREE.CatmullRomCurve3(points);
    const geo = new THREE.TubeGeometry(curve, segments, 0.02, 8, false);
    const mat = new THREE.MeshPhysicalMaterial({
      color: new THREE.Color(colors[i % colors.length]),
      transparent: true,
      opacity: 0.6,
      metalness: 1.0,
      roughness: 0.0,
      emissive: new THREE.Color(colors[i % colors.length]).multiplyScalar(0.4),
    });

    addPortalShader(mat);
    const stream = new THREE.Mesh(geo, mat);
    scene.add(stream);
    meshes.push(stream);
  }
}

function createPortalFrame() {
  const frameGeo = new THREE.TorusGeometry(7, 0.2, 16, 64);
  const frameMat = new THREE.MeshPhysicalMaterial({
    color: new THREE.Color(params.value.primaryColor),
    transparent: true,
    opacity: 0.4,
    metalness: 1.0,
    roughness: 0.1,
    clearcoat: 1.0,
    clearcoatRoughness: 0.0,
    emissive: new THREE.Color(params.value.primaryColor).multiplyScalar(0.5),
  });

  addPortalShader(frameMat);
  const frame = new THREE.Mesh(frameGeo, frameMat);
  scene.add(frame);
  meshes.push(frame);
}

function createEnergyParticles() {
  const count = 1500;
  const geo = new THREE.BufferGeometry();
  const positions = new Float32Array(count * 3);

  for (let i = 0; i < count; i++) {
    const r = 2 + Math.random() * 8;
    const theta = Math.random() * Math.PI * 2;
    const phi = Math.acos(2 * Math.random() - 1);

    positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
    positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
    positions[i * 3 + 2] = r * Math.cos(phi);
  }

  geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));

  const mat = new THREE.PointsMaterial({
    size: 0.08,
    color: params.value.vortexColor,
    blending: THREE.AdditiveBlending,
    transparent: true,
    opacity: 0.8,
  });

  const particles = new THREE.Points(geo, mat);
  scene.add(particles);
  meshes.push(particles);
  materials.push(mat);
}

function createSpaceDistortion() {
  const geo = new THREE.SphereGeometry(12, 64, 64);
  const mat = new THREE.ShaderMaterial({
    uniforms: {
      time: { value: 0 },
      dimensionShift: { value: 0 },
      color1: { value: new THREE.Color(params.value.primaryColor) },
      color2: { value: new THREE.Color(params.value.vortexColor) },
    },
    vertexShader: `
      uniform float time;
      uniform float dimensionShift;
      varying vec3 vNorm;
      varying vec3 vPos;
      void main() {
        vNorm = normal;
        vPos = position;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
      }
    `,
    fragmentShader: `
      uniform float time;
      uniform float dimensionShift;
      uniform vec3 color1;
      uniform vec3 color2;
      varying vec3 vNorm;
      varying vec3 vPos;
      void main() {
        vec3 viewDir = normalize(cameraPosition - vPos);
        float fresnel = pow(1.0 - abs(dot(vNorm, viewDir)), 4.0);

        float distortion = sin(vPos.x * 0.5 + time * 2.0) * cos(vPos.y * 0.7 + time * 1.5);
        vec3 color = mix(color1, color2, distortion * 0.5 + 0.5 + dimensionShift * 0.3);

        gl_FragColor = vec4(color, fresnel * 0.3);
      }
    `,
    transparent: true,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  });

  const distortion = new THREE.Mesh(geo, mat);
  scene.add(distortion);
  meshes.push(distortion);
  materials.push(mat);
}

function createPortalScene() {
  // Clean up existing meshes
  meshes.forEach((mesh) => scene.remove(mesh));
  materials.forEach((mat) => mat.dispose());
  meshes = [];
  materials = [];
  portalMaterials = [];

  // Create portal components
  createCosmicBackground();
  createPortalCore();
  createVortexRings();
  createFloatingCrystals();
  createDimensionalStreams();
  createPortalFrame();
  createEnergyParticles();
  createSpaceDistortion();
}

function handleResize() {
  if (!camera || !renderer || !composer || !canvasRef.value) return;

  const container = canvasRef.value.parentElement;
  if (!container) return;

  const width = container.clientWidth;
  const height = container.clientHeight;

  // Update camera
  camera.aspect = width / height;
  camera.updateProjectionMatrix();

  // Update renderer and composer
  renderer.setSize(width, height);
  composer.setSize(width, height);

  // Update FXAA pass
  const pixelRatio = renderer.getPixelRatio();
  fxaaPass.material.uniforms["resolution"].value.set(
    1 / (width * pixelRatio),
    1 / (height * pixelRatio),
  );
}

function animate() {
  animationId = requestAnimationFrame(animate);

  const delta = clock.getDelta();
  time = clock.getElapsedTime();

  // Update shader uniforms
  portalMaterials.forEach((shader) => {
    if (shader.uniforms) {
      if (shader.uniforms.time) shader.uniforms.time.value = time;
      if (shader.uniforms.dimensionShift)
        shader.uniforms.dimensionShift.value = params.value.dimensionShift;
    }
  });

  materials.forEach((mat) => {
    if (mat.uniforms) {
      if (mat.uniforms.time) mat.uniforms.time.value = time;
      if (mat.uniforms.dimensionShift)
        mat.uniforms.dimensionShift.value = params.value.dimensionShift;
    }
  });

  // Animate portal lights
  portalLights.forEach((light, i) => {
    const angle = time * 0.3 + (i / 6) * Math.PI * 2;
    const radius = 10 + Math.sin(time * 0.5 + i) * 3;
    light.position.x = Math.cos(angle) * radius;
    light.position.z = Math.sin(angle) * radius;
    light.position.y = Math.sin(time * 0.4 + i * 0.7) * 5;
  });

  // Animate meshes
  meshes.forEach((mesh, i) => {
    if (!mesh.rotation) return;
    const speed = params.value.rotationSpeed;
    mesh.rotation.y += delta * speed * (i % 2 ? -1 : 1) * 0.3;
    mesh.rotation.x += delta * speed * 0.1;

    // Animate particle positions
    if (mesh.material && mesh.material.type === "PointsMaterial") {
      const positions = mesh.geometry.attributes.position.array;
      for (let j = 0; j < positions.length; j += 3) {
        positions[j] += Math.sin(time + j) * 0.001;
        positions[j + 1] += Math.cos(time + j) * 0.001;
        positions[j + 2] += Math.sin(time * 0.7 + j) * 0.001;
      }
      mesh.geometry.attributes.position.needsUpdate = true;
    }
  });

  controls.update();
  composer.render();
}

function cleanup() {
  if (animationId) {
    cancelAnimationFrame(animationId);
  }

  // Dispose of Three.js objects
  meshes.forEach((mesh) => {
    if (mesh.geometry) mesh.geometry.dispose();
    if (mesh.material) {
      if (Array.isArray(mesh.material)) {
        mesh.material.forEach((mat) => mat.dispose());
      } else {
        mesh.material.dispose();
      }
    }
  });

  materials.forEach((mat) => mat.dispose());

  if (renderer) {
    renderer.dispose();
  }

  if (controls) {
    controls.dispose();
  }

  window.removeEventListener("resize", handleResize);
}

// Exposed methods
function activatePortal() {
  portalMaterials.forEach((mat) => {
    if (mat.uniforms && mat.uniforms.pulseTime) {
      mat.uniforms.pulseTime.value = time;
    }
  });
}

function shiftDimensions() {
  const colors = [
    "#9b59b6",
    "#3498db",
    "#e74c3c",
    "#2ecc71",
    "#f39c12",
    "#e67e22",
    "#1abc9c",
    "#34495e",
  ];
  params.value.primaryColor = colors[Math.floor(Math.random() * colors.length)];
  params.value.secondaryColor = colors[Math.floor(Math.random() * colors.length)];
  params.value.accentColor = colors[Math.floor(Math.random() * colors.length)];
  params.value.vortexColor = colors[Math.floor(Math.random() * colors.length)];
  params.value.dimensionShift = Math.random();
  createPortalScene();
}

// Expose methods to parent component
defineExpose({
  activatePortal,
  shiftDimensions,
});

onMounted(() => {
  initThreeJS();
  createPortalScene();
  // Use ResizeObserver instead of window resize
  if (canvasRef.value?.parentElement) {
    resizeObserver = new ResizeObserver(() => {
      handleResize();
    });
    resizeObserver.observe(canvasRef.value.parentElement);
  }
  animate();
});

onUnmounted(() => {
  cleanup();
  if (resizeObserver) {
    resizeObserver.disconnect();
  }
});
</script>

```

## 感谢

- 灵感和移植自 [Dimensional Portal by Techartist](https://x.com/techartist_).