mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
194 lines
6.1 KiB
TypeScript
194 lines
6.1 KiB
TypeScript
'use client';
|
|
|
|
import * as THREE from 'three';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { ConvexGeometry } from 'three/addons/geometries/ConvexGeometry.js';
|
|
|
|
import { useT } from '../lib/i18n/I18nContext';
|
|
|
|
interface RotatingTetrahedronCanvasProps {
|
|
inverted?: boolean;
|
|
}
|
|
|
|
/** Start from a regular tetrahedron and lightly truncate each corner to create small blunted edges. */
|
|
function bluntedTetrahedronPoints(scale: number, bluntness = 0.12): THREE.Vector3[] {
|
|
const tetra = [
|
|
new THREE.Vector3(1, 1, 1),
|
|
new THREE.Vector3(-1, -1, 1),
|
|
new THREE.Vector3(-1, 1, -1),
|
|
new THREE.Vector3(1, -1, -1),
|
|
];
|
|
|
|
const points: THREE.Vector3[] = [];
|
|
|
|
for (let i = 0; i < tetra.length; i += 1) {
|
|
for (let j = 0; j < tetra.length; j += 1) {
|
|
if (i === j) continue;
|
|
points.push(tetra[i].clone().lerp(tetra[j], bluntness).multiplyScalar(scale));
|
|
}
|
|
}
|
|
|
|
return points;
|
|
}
|
|
|
|
export default function RotatingTetrahedronCanvas({
|
|
inverted = false,
|
|
}: RotatingTetrahedronCanvasProps) {
|
|
const { t } = useT();
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const fillMaterialRef = useRef<THREE.MeshLambertMaterial | null>(null);
|
|
const edgeMaterialRef = useRef<THREE.LineBasicMaterial | null>(null);
|
|
const speedRef = useRef(2);
|
|
const [webglFailed, setWebglFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const fillMaterial = fillMaterialRef.current;
|
|
const edgeMaterial = edgeMaterialRef.current;
|
|
if (!fillMaterial || !edgeMaterial) {
|
|
return;
|
|
}
|
|
|
|
fillMaterial.color.set(inverted ? '#0f172a' : '#dbeafe');
|
|
fillMaterial.opacity = inverted ? 0.1 : 0.72;
|
|
fillMaterial.emissive.set(inverted ? '#020617' : '#334155');
|
|
fillMaterial.emissiveIntensity = inverted ? 0.4 : 0.35;
|
|
fillMaterial.needsUpdate = true;
|
|
|
|
edgeMaterial.color.set(inverted ? '#f8fafc' : '#f8fafc');
|
|
edgeMaterial.needsUpdate = true;
|
|
|
|
speedRef.current = inverted ? 20 : 2;
|
|
}, [inverted]);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
// Verify a WebGL context can be obtained before handing the canvas to
|
|
// Three.js. `THREE.WebGLRenderer` internally calls `gl.createShader()`
|
|
// which throws if the context is null (e.g. when another canvas already
|
|
// consumed the platform's WebGL context limit).
|
|
const testCtx =
|
|
canvas.getContext('webgl2', { antialias: true }) ||
|
|
canvas.getContext('webgl', { antialias: true });
|
|
if (!testCtx) {
|
|
console.warn('[RotatingTetrahedronCanvas] WebGL context unavailable — skipping');
|
|
setWebglFailed(true);
|
|
return;
|
|
}
|
|
|
|
// Lose the test context so Three.js can create its own on the same canvas.
|
|
// getContext returns the same context when called with the same type, so
|
|
// Three.js will reuse it. We just needed the null-check above.
|
|
|
|
let renderer: THREE.WebGLRenderer;
|
|
try {
|
|
renderer = new THREE.WebGLRenderer({
|
|
canvas,
|
|
context: testCtx as WebGLRenderingContext,
|
|
antialias: true,
|
|
alpha: true,
|
|
powerPreference: 'high-performance',
|
|
});
|
|
} catch (err) {
|
|
console.warn('[RotatingTetrahedronCanvas] WebGLRenderer init failed:', err);
|
|
setWebglFailed(true);
|
|
return;
|
|
}
|
|
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
renderer.toneMapping = THREE.NoToneMapping;
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
|
|
camera.position.set(0, 0.15, 4.8);
|
|
|
|
const geometry = new ConvexGeometry(bluntedTetrahedronPoints(0.98, 0.11));
|
|
const fillMaterial = new THREE.MeshLambertMaterial({
|
|
color: inverted ? '#0f172a' : '#dbeafe',
|
|
transparent: true,
|
|
opacity: inverted ? 0.58 : 0.72,
|
|
emissive: inverted ? '#020617' : '#334155',
|
|
emissiveIntensity: inverted ? 0.4 : 0.35,
|
|
});
|
|
fillMaterialRef.current = fillMaterial;
|
|
const fillMesh = new THREE.Mesh(geometry, fillMaterial);
|
|
|
|
const edgeGeometry = new THREE.EdgesGeometry(geometry);
|
|
const edgeMaterial = new THREE.LineBasicMaterial({ color: inverted ? '#020617' : '#f8fafc' });
|
|
edgeMaterialRef.current = edgeMaterial;
|
|
|
|
const edges = new THREE.LineSegments(edgeGeometry, edgeMaterial);
|
|
fillMesh.rotation.x = 0.35;
|
|
fillMesh.rotation.y = -0.15;
|
|
edges.rotation.x = 0.35;
|
|
edges.rotation.y = -0.15;
|
|
scene.add(fillMesh);
|
|
scene.add(edges);
|
|
|
|
const ambientLight = new THREE.AmbientLight('#ffffff', 0.1);
|
|
const sun = new THREE.DirectionalLight('#ffffff', 1.1);
|
|
sun.position.set(4.5, 6, 5);
|
|
scene.add(ambientLight);
|
|
scene.add(sun);
|
|
|
|
let animationFrame = 0;
|
|
|
|
const resize = () => {
|
|
const parent = canvas.parentElement;
|
|
if (!parent) return;
|
|
|
|
const { width, height } = parent.getBoundingClientRect();
|
|
if (!width || !height) return;
|
|
|
|
renderer.setSize(width, height, false);
|
|
camera.aspect = width / height;
|
|
camera.updateProjectionMatrix();
|
|
};
|
|
|
|
const observer = new ResizeObserver(resize);
|
|
if (canvas.parentElement) observer.observe(canvas.parentElement);
|
|
resize();
|
|
|
|
speedRef.current = inverted ? 7 : 2;
|
|
const animate = () => {
|
|
const speed = speedRef.current;
|
|
fillMesh.rotation.y += 0.0038 * speed;
|
|
fillMesh.rotation.x += 0.0002 * speed;
|
|
edges.rotation.y += 0.0038 * speed;
|
|
edges.rotation.x += 0.0002 * speed;
|
|
|
|
renderer.render(scene, camera);
|
|
animationFrame = window.requestAnimationFrame(animate);
|
|
};
|
|
|
|
animate();
|
|
|
|
return () => {
|
|
window.cancelAnimationFrame(animationFrame);
|
|
observer.disconnect();
|
|
edgeGeometry.dispose();
|
|
geometry.dispose();
|
|
fillMaterial.dispose();
|
|
edgeMaterial.dispose();
|
|
fillMaterialRef.current = null;
|
|
edgeMaterialRef.current = null;
|
|
renderer.dispose();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Scene created once; inverted changes handled by separate effect.
|
|
}, []);
|
|
|
|
if (webglFailed) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
style={{ width: '100%', height: '100%', display: 'block' }}
|
|
aria-label={t('art.rotatingTetrahedronAria')}
|
|
/>
|
|
);
|
|
}
|