177 lines
5.4 KiB
TypeScript
177 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
type Tr = (zh: string, en: string) => string;
|
|
|
|
export function LiveStudioScene3D({ tr }: { tr: Tr }) {
|
|
const mountRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = mountRef.current;
|
|
if (!el) return;
|
|
|
|
let disposed = false;
|
|
let animationId = 0;
|
|
let teardown: (() => void) | undefined;
|
|
|
|
void import("three").then((THREE) => {
|
|
if (disposed || !mountRef.current) return;
|
|
|
|
const mount = mountRef.current;
|
|
const width = Math.max(mount.clientWidth, 280);
|
|
const height = Math.max(mount.clientHeight, 200);
|
|
|
|
const scene = new THREE.Scene();
|
|
|
|
const camera = new THREE.PerspectiveCamera(48, width / height, 0.1, 80);
|
|
camera.position.set(0, 1.1, 7.2);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
renderer.setPixelRatio(Math.min(typeof window !== "undefined" ? window.devicePixelRatio : 1, 2));
|
|
renderer.setSize(width, height);
|
|
renderer.setClearColor(0x000000, 0);
|
|
mount.appendChild(renderer.domElement);
|
|
|
|
const hemi = new THREE.HemisphereLight(0xffc4ec, 0x4466ff, 0.85);
|
|
scene.add(hemi);
|
|
const dir = new THREE.DirectionalLight(0xffffff, 0.9);
|
|
dir.position.set(4, 8, 6);
|
|
scene.add(dir);
|
|
const back = new THREE.PointLight(0x7cf5ff, 0.6, 20);
|
|
back.position.set(-3, 2, -2);
|
|
scene.add(back);
|
|
|
|
const group = new THREE.Group();
|
|
|
|
const studio = new THREE.Mesh(
|
|
new THREE.BoxGeometry(3.2, 0.35, 2.2),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x2a2540,
|
|
metalness: 0.15,
|
|
roughness: 0.55,
|
|
}),
|
|
);
|
|
studio.position.y = -0.85;
|
|
group.add(studio);
|
|
|
|
const ring = new THREE.Mesh(
|
|
new THREE.TorusGeometry(0.72, 0.09, 12, 48),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0xff3355,
|
|
emissive: 0x440011,
|
|
metalness: 0.35,
|
|
roughness: 0.25,
|
|
}),
|
|
);
|
|
ring.rotation.x = Math.PI / 2;
|
|
ring.position.set(-0.85, 0.15, 0.2);
|
|
group.add(ring);
|
|
|
|
const play = new THREE.Mesh(
|
|
new THREE.ConeGeometry(0.35, 0.5, 3),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0xffffff,
|
|
emissive: 0x222222,
|
|
metalness: 0.2,
|
|
roughness: 0.3,
|
|
}),
|
|
);
|
|
play.rotation.z = -Math.PI / 2;
|
|
play.rotation.y = Math.PI;
|
|
play.position.set(-0.55, 0.18, 0.2);
|
|
group.add(play);
|
|
|
|
const tk = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.22, 0.28, 0.95, 20),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0x25f4ee,
|
|
emissive: 0x053038,
|
|
metalness: 0.25,
|
|
roughness: 0.35,
|
|
}),
|
|
);
|
|
tk.position.set(1.05, 0.35, -0.15);
|
|
tk.rotation.z = 0.12;
|
|
group.add(tk);
|
|
|
|
const tkCap = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.26, 16, 16),
|
|
new THREE.MeshStandardMaterial({
|
|
color: 0xfe2c55,
|
|
emissive: 0x220510,
|
|
metalness: 0.2,
|
|
roughness: 0.4,
|
|
}),
|
|
);
|
|
tkCap.position.set(1.05, 0.92, -0.15);
|
|
group.add(tkCap);
|
|
|
|
const starGeo = new THREE.BufferGeometry();
|
|
const starCount = 120;
|
|
const positions = new Float32Array(starCount * 3);
|
|
for (let i = 0; i < starCount; i++) {
|
|
positions[i * 3] = (Math.random() - 0.5) * 14;
|
|
positions[i * 3 + 1] = (Math.random() - 0.3) * 8;
|
|
positions[i * 3 + 2] = (Math.random() - 0.5) * 8 - 2;
|
|
}
|
|
starGeo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
|
const stars = new THREE.Points(
|
|
starGeo,
|
|
new THREE.PointsMaterial({ color: 0xffffff, size: 0.06, transparent: true, opacity: 0.7 }),
|
|
);
|
|
scene.add(stars);
|
|
|
|
scene.add(group);
|
|
|
|
const t0 = performance.now();
|
|
const tick = () => {
|
|
if (disposed) return;
|
|
animationId = requestAnimationFrame(tick);
|
|
const t = (performance.now() - t0) / 1000;
|
|
group.rotation.y = Math.sin(t * 0.35) * 0.25 + t * 0.12;
|
|
group.position.y = Math.sin(t * 0.9) * 0.06;
|
|
ring.rotation.z = t * 0.4;
|
|
stars.rotation.y = t * 0.02;
|
|
renderer.render(scene, camera);
|
|
};
|
|
tick();
|
|
|
|
const ro = new ResizeObserver(() => {
|
|
if (!mountRef.current || disposed) return;
|
|
const w = Math.max(mountRef.current.clientWidth, 280);
|
|
const h = Math.max(mountRef.current.clientHeight, 200);
|
|
camera.aspect = w / h;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(w, h);
|
|
});
|
|
ro.observe(mount);
|
|
|
|
teardown = () => {
|
|
cancelAnimationFrame(animationId);
|
|
ro.disconnect();
|
|
renderer.dispose();
|
|
starGeo.dispose();
|
|
if (renderer.domElement.parentNode === mount) {
|
|
mount.removeChild(renderer.domElement);
|
|
}
|
|
};
|
|
});
|
|
|
|
return () => {
|
|
disposed = true;
|
|
cancelAnimationFrame(animationId);
|
|
teardown?.();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex h-full min-h-[14rem] flex-col">
|
|
<p className="mb-2 px-1 text-[10px] font-medium uppercase tracking-wider text-fuchsia-200/80">
|
|
{tr("直播工作室 · 3D", "Live studio · 3D")}
|
|
</p>
|
|
<div ref={mountRef} className="relative min-h-[12rem] flex-1 overflow-hidden rounded-xl bg-gradient-to-b from-fuchsia-950/40 to-cyan-950/20 ring-1 ring-white/10" />
|
|
</div>
|
|
);
|
|
}
|