Files
gitlab-instance-0a899031_no…/app/topic/components/TopicPrepThreeScene.tsx
2026-03-27 18:38:36 -05:00

587 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
export type TopicPrepVariant = "indie" | "aiagent";
type Props = {
variant: TopicPrepVariant;
};
function disposeObject3D(root: THREE.Object3D) {
const materials = new Set<THREE.Material>();
const geometries = new Set<THREE.BufferGeometry>();
root.traverse((object) => {
if (object instanceof THREE.Mesh || object instanceof THREE.LineSegments) {
const g = object.geometry;
if (g && !geometries.has(g)) {
geometries.add(g);
g.dispose();
}
const mat = object.material;
if (Array.isArray(mat)) mat.forEach((m) => materials.add(m));
else if (mat) materials.add(mat);
}
});
materials.forEach((m) => m.dispose());
}
function applyFilmicRenderer(renderer: THREE.WebGLRenderer, exposure: number) {
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = exposure;
}
/** 独立开发:三面墙+顶+地 半封闭小房间(朝镜头开口),萌系配色与小狗 */
function buildIndieEnclosedRoom(root: THREE.Group): { dog: THREE.Group; tail: THREE.Mesh } {
const wall = new THREE.MeshStandardMaterial({
color: 0xfff0f3,
roughness: 0.94,
metalness: 0,
});
const floorMat = new THREE.MeshStandardMaterial({
color: 0xf5e0d4,
roughness: 0.88,
metalness: 0,
});
const ceilMat = new THREE.MeshStandardMaterial({
color: 0xfff7fb,
roughness: 0.92,
metalness: 0,
});
const floor = new THREE.Mesh(new THREE.PlaneGeometry(5.2, 4.2), floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1;
root.add(floor);
const back = new THREE.Mesh(new THREE.PlaneGeometry(5.2, 3.2), wall);
back.position.set(0, 0.6, -2.1);
root.add(back);
const left = new THREE.Mesh(new THREE.PlaneGeometry(4.2, 3.2), wall);
left.rotation.y = Math.PI / 2;
left.position.set(-2.6, 0.6, 0);
root.add(left);
const right = new THREE.Mesh(new THREE.PlaneGeometry(4.2, 3.2), wall);
right.rotation.y = -Math.PI / 2;
right.position.set(2.6, 0.6, 0);
root.add(right);
const ceiling = new THREE.Mesh(new THREE.PlaneGeometry(5.2, 4.2), ceilMat);
ceiling.rotation.x = Math.PI / 2;
ceiling.position.y = 2.2;
root.add(ceiling);
const lamp = new THREE.Mesh(
new THREE.PlaneGeometry(1.4, 0.9),
new THREE.MeshStandardMaterial({
color: 0xffffff,
emissive: 0xfff1f2,
emissiveIntensity: 0.55,
roughness: 1,
}),
);
lamp.rotation.x = Math.PI / 2;
lamp.position.set(0, 2.18, 0.2);
root.add(lamp);
const deskMat = new THREE.MeshStandardMaterial({
color: 0xf5e6d8,
roughness: 0.5,
metalness: 0.04,
});
const deskTop = new THREE.Mesh(new THREE.BoxGeometry(1.65, 0.055, 0.72), deskMat);
deskTop.position.set(-0.25, -0.45, 0.05);
root.add(deskTop);
const legGeo = new THREE.CylinderGeometry(0.038, 0.042, 0.36, 14);
const legMat = new THREE.MeshStandardMaterial({ color: 0xe8d5c4, roughness: 0.58 });
for (const [x, z] of [
[-0.72, -0.22],
[0.22, -0.22],
[-0.72, 0.38],
[0.22, 0.38],
]) {
const leg = new THREE.Mesh(legGeo, legMat);
leg.position.set(x, -0.66, z);
root.add(leg);
}
const bezel = new THREE.MeshStandardMaterial({
color: 0x3f3f46,
roughness: 0.42,
metalness: 0.12,
});
const monitor = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.42, 0.028), bezel);
monitor.position.set(-0.25, -0.08, -0.38);
root.add(monitor);
const screenMat = new THREE.MeshStandardMaterial({
color: 0xa5d8ff,
emissive: 0xc5eaff,
emissiveIntensity: 0.28,
roughness: 0.4,
});
const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.56, 0.36), screenMat);
screen.position.set(-0.25, -0.08, -0.38 + 0.015);
root.add(screen);
const kb = new THREE.Mesh(
new THREE.BoxGeometry(0.48, 0.022, 0.18),
new THREE.MeshStandardMaterial({ color: 0xa8a29e, roughness: 0.55 }),
);
kb.position.set(-0.25, -0.4, 0.22);
kb.rotation.x = -0.035;
root.add(kb);
const tower = new THREE.Mesh(
new THREE.BoxGeometry(0.18, 0.36, 0.32),
new THREE.MeshStandardMaterial({ color: 0xfafafa, roughness: 0.32 }),
);
tower.position.set(0.38, -0.38, -0.28);
root.add(tower);
const chairSeat = new THREE.Mesh(
new THREE.BoxGeometry(0.4, 0.055, 0.36),
new THREE.MeshStandardMaterial({ color: 0x78716c, roughness: 0.72 }),
);
chairSeat.position.set(-0.25, -0.72, 0.55);
root.add(chairSeat);
const fur = new THREE.MeshStandardMaterial({
color: 0xffc4a8,
roughness: 0.82,
metalness: 0,
});
const furDeep = new THREE.MeshStandardMaterial({
color: 0xf59e7c,
roughness: 0.85,
metalness: 0,
});
const dog = new THREE.Group();
dog.position.set(0.75, -0.86, 0.22);
const body = new THREE.Mesh(new THREE.SphereGeometry(0.2, 22, 18), fur);
body.scale.set(1.05, 0.78, 1.12);
body.position.y = 0.11;
dog.add(body);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.16, 20, 18), fur);
head.position.set(0, 0.3, 0.1);
dog.add(head);
const snout = new THREE.Mesh(new THREE.SphereGeometry(0.065, 14, 14), furDeep);
snout.position.set(0, 0.26, 0.22);
snout.scale.set(1, 0.88, 1.15);
dog.add(snout);
const nose = new THREE.Mesh(
new THREE.SphereGeometry(0.022, 10, 10),
new THREE.MeshStandardMaterial({ color: 0x292524, roughness: 0.35 }),
);
nose.position.set(0, 0.28, 0.3);
dog.add(nose);
const eyeWhiteL = new THREE.Mesh(
new THREE.SphereGeometry(0.045, 12, 12),
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.25 }),
);
eyeWhiteL.position.set(-0.055, 0.32, 0.18);
const eyeWhiteR = eyeWhiteL.clone();
eyeWhiteR.position.x = 0.055;
dog.add(eyeWhiteL, eyeWhiteR);
const pupilL = new THREE.Mesh(
new THREE.SphereGeometry(0.022, 10, 10),
new THREE.MeshStandardMaterial({ color: 0x1c1917, roughness: 0.2 }),
);
pupilL.position.set(-0.052, 0.32, 0.2);
const pupilR = pupilL.clone();
pupilR.position.x = 0.052;
dog.add(pupilL, pupilR);
const blushMat = new THREE.MeshStandardMaterial({
color: 0xff9eb5,
roughness: 0.6,
emissive: 0xff8fab,
emissiveIntensity: 0.12,
});
const blushL = new THREE.Mesh(new THREE.SphereGeometry(0.035, 10, 10), blushMat);
blushL.scale.set(1.2, 0.45, 0.6);
blushL.position.set(-0.1, 0.24, 0.16);
const blushR = blushL.clone();
blushR.position.x = 0.1;
dog.add(blushL, blushR);
const earL = new THREE.Mesh(new THREE.SphereGeometry(0.055, 12, 12), furDeep);
earL.position.set(-0.11, 0.36, 0.05);
earL.scale.set(0.45, 0.95, 0.4);
const earR = earL.clone();
earR.position.x = 0.11;
dog.add(earL, earR);
const tail = new THREE.Mesh(
new THREE.CylinderGeometry(0.028, 0.036, 0.2, 12),
furDeep,
);
tail.rotation.z = Math.PI / 2.8;
tail.rotation.x = 0.25;
tail.position.set(-0.18, 0.16, -0.06);
dog.add(tail);
for (const [x, z] of [
[-0.09, 0.07],
[0.09, 0.07],
[-0.07, -0.07],
[0.07, -0.07],
]) {
const paw = new THREE.Mesh(new THREE.SphereGeometry(0.048, 12, 12), furDeep);
paw.scale.set(1, 0.58, 1);
paw.position.set(x, 0, z);
dog.add(paw);
}
root.add(dog);
return { dog, tail };
}
/** AI赛博空间无网格线萌系圆润机器人 + 实体光晕环(非线框) */
function buildAiCuteCyber(root: THREE.Group): {
bot: THREE.Group;
halo: THREE.Mesh;
orbs: THREE.Mesh[];
} {
const backdrop = new THREE.Mesh(
new THREE.SphereGeometry(16, 48, 48),
new THREE.MeshBasicMaterial({ color: 0x0c0618, side: THREE.BackSide }),
);
root.add(backdrop);
const floor = new THREE.Mesh(
new THREE.CircleGeometry(4.5, 64),
new THREE.MeshStandardMaterial({
color: 0x1e1435,
roughness: 0.55,
metalness: 0.15,
emissive: 0x4c1d95,
emissiveIntensity: 0.06,
}),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -1.05;
root.add(floor);
const haloMat = new THREE.MeshStandardMaterial({
color: 0xa78bfa,
emissive: 0x8b5cf6,
emissiveIntensity: 0.25,
roughness: 0.35,
metalness: 0.25,
transparent: true,
opacity: 0.55,
side: THREE.DoubleSide,
});
const halo = new THREE.Mesh(new THREE.TorusGeometry(1.05, 0.035, 16, 64), haloMat);
halo.rotation.x = Math.PI / 2;
halo.position.y = -0.35;
root.add(halo);
const shell = new THREE.MeshStandardMaterial({
color: 0xe8e0ff,
roughness: 0.42,
metalness: 0.18,
});
const shellDark = new THREE.MeshStandardMaterial({
color: 0xc4b5fd,
roughness: 0.45,
metalness: 0.22,
});
const eyeGlow = new THREE.MeshStandardMaterial({
color: 0x67e8f9,
emissive: 0x22d3ee,
emissiveIntensity: 0.35,
roughness: 0.28,
});
const bot = new THREE.Group();
bot.position.set(0, -0.25, 0);
const body = new THREE.Mesh(new THREE.SphereGeometry(0.32, 28, 24), shell);
body.scale.set(1, 0.88, 0.92);
body.position.y = 0.05;
bot.add(body);
const tummy = new THREE.Mesh(new THREE.CircleGeometry(0.18, 24), shellDark);
tummy.rotation.x = -0.15;
tummy.position.set(0, 0.02, 0.27);
bot.add(tummy);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.26, 28, 24), shell);
head.position.y = 0.48;
bot.add(head);
const eyeWL = new THREE.Mesh(new THREE.SphereGeometry(0.07, 14, 14), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.2 }));
eyeWL.position.set(-0.08, 0.5, 0.22);
const eyeWR = eyeWL.clone();
eyeWR.position.x = 0.08;
bot.add(eyeWL, eyeWR);
const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.038, 12, 12), eyeGlow);
eyeL.position.set(-0.078, 0.5, 0.26);
const eyeR = eyeL.clone();
eyeR.position.x = 0.078;
bot.add(eyeL, eyeR);
const cheek = new THREE.MeshStandardMaterial({
color: 0xf9a8d4,
emissive: 0xf472b6,
emissiveIntensity: 0.15,
roughness: 0.5,
});
const chL = new THREE.Mesh(new THREE.SphereGeometry(0.04, 10, 10), cheek);
chL.scale.set(1.3, 0.5, 0.5);
chL.position.set(-0.14, 0.42, 0.2);
const chR = chL.clone();
chR.position.x = 0.14;
bot.add(chL, chR);
const antStem = new THREE.Mesh(
new THREE.CylinderGeometry(0.02, 0.025, 0.16, 10),
shellDark,
);
antStem.position.set(0, 0.72, 0);
bot.add(antStem);
const antBall = new THREE.Mesh(new THREE.SphereGeometry(0.055, 16, 16), eyeGlow);
antBall.position.set(0, 0.82, 0);
bot.add(antBall);
const armGeo = new THREE.CapsuleGeometry(0.07, 0.22, 6, 12);
const armL = new THREE.Mesh(armGeo, shell);
armL.position.set(-0.42, 0.08, 0);
armL.rotation.z = 0.35;
const armR = new THREE.Mesh(armGeo, shell);
armR.position.set(0.42, 0.08, 0);
armR.rotation.z = -0.35;
bot.add(armL, armR);
const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.09, 0.12, 6, 12), shellDark);
leg.position.set(-0.12, -0.32, 0.05);
const leg2 = leg.clone();
leg2.position.x = 0.12;
bot.add(leg, leg2);
root.add(bot);
const orbMat = [
new THREE.MeshStandardMaterial({ color: 0xf0abfc, roughness: 0.35, metalness: 0.2 }),
new THREE.MeshStandardMaterial({ color: 0x7dd3fc, roughness: 0.35, metalness: 0.2 }),
new THREE.MeshStandardMaterial({ color: 0xfde68a, roughness: 0.35, metalness: 0.15 }),
];
const orbs: THREE.Mesh[] = [];
for (let i = 0; i < 3; i++) {
const o = new THREE.Mesh(new THREE.SphereGeometry(0.09, 16, 16), orbMat[i]);
const a = (i / 3) * Math.PI * 2;
o.position.set(Math.cos(a) * 1.15, 0.25 + i * 0.08, Math.sin(a) * 1.15);
root.add(o);
orbs.push(o);
}
return { bot, halo, orbs };
}
export function TopicPrepThreeScene({ variant }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: false,
powerPreference: "high-performance",
});
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(0.35, 0.45, 3.85);
const group = new THREE.Group();
scene.add(group);
let anim: {
dog?: THREE.Group;
tail?: THREE.Mesh;
bot?: THREE.Group;
halo?: THREE.Mesh;
orbs?: THREE.Mesh[];
} | null = null;
if (variant === "indie") {
scene.fog = new THREE.Fog(0xfff5f7, 3.8, 11);
applyFilmicRenderer(renderer, 1.02);
const hemi = new THREE.HemisphereLight(0xffffff, 0xfce7f3, 0.95);
scene.add(hemi);
const key = new THREE.DirectionalLight(0xffffff, 0.48);
key.position.set(2, 5, 3);
scene.add(key);
const fill = new THREE.DirectionalLight(0xffe4e8, 0.28);
fill.position.set(-3, 2, -1);
scene.add(fill);
const scr = new THREE.PointLight(0xbfdbfe, 0.35, 5);
scr.position.set(-0.25, 0, 0);
scene.add(scr);
anim = buildIndieEnclosedRoom(group);
group.rotation.set(0.1, 0.28, 0);
} else {
scene.fog = new THREE.FogExp2(0x0c0618, 0.048);
applyFilmicRenderer(renderer, 1.05);
const hemi = new THREE.HemisphereLight(0xc4b5fd, 0x1e1038, 0.75);
scene.add(hemi);
const key = new THREE.DirectionalLight(0xf5f3ff, 0.5);
key.position.set(2, 4.5, 3);
scene.add(key);
const rim = new THREE.DirectionalLight(0x7c3aed, 0.22);
rim.position.set(-2.5, 1, -1.5);
scene.add(rim);
const glow = new THREE.PointLight(0xa78bfa, 0.4, 9);
glow.position.set(0.8, 1.2, 1);
scene.add(glow);
anim = buildAiCuteCyber(group);
group.rotation.set(0.06, -0.15, 0);
}
let rx = group.rotation.x;
let ry = group.rotation.y;
let vx = 0;
let vy = 0;
const sens = 0.0046;
const damping = 0.936;
const autoRy = variant === "indie" ? 0.001 : 0.0013;
let dragging = false;
let lastX = 0;
let lastY = 0;
const onPointerDown = (e: PointerEvent) => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
canvas.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!dragging) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
vy += dx * sens;
vx += dy * sens;
};
const onPointerEnd = (e: PointerEvent) => {
dragging = false;
try {
canvas.releasePointerCapture(e.pointerId);
} catch {
/* already released */
}
};
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerEnd);
canvas.addEventListener("pointercancel", onPointerEnd);
canvas.style.touchAction = "none";
const resize = () => {
const w = Math.max(280, container.clientWidth);
const h = Math.min(440, Math.max(240, Math.round(w * 0.52)));
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(w, h, false);
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize();
let raf = 0;
const t0 = performance.now();
const tick = () => {
raf = requestAnimationFrame(tick);
const t = (performance.now() - t0) * 0.001;
if (!dragging) vy += autoRy;
rx += vx;
ry += vy;
vx *= damping;
vy *= damping;
group.rotation.x = rx;
group.rotation.y = ry;
if (anim?.dog) {
anim.dog.position.y = -0.86 + Math.sin(t * 2) * 0.016;
anim.dog.rotation.y = Math.sin(t * 0.85) * 0.05;
}
if (anim?.tail) {
anim.tail.rotation.z = Math.PI / 2.8 + Math.sin(t * 4.5) * 0.22;
}
if (anim?.bot) {
anim.bot.position.y = -0.25 + Math.sin(t * 1.5) * 0.03;
anim.bot.rotation.y = Math.sin(t * 0.6) * 0.04;
}
if (anim?.halo) {
anim.halo.rotation.z = t * 0.15;
}
if (anim?.orbs) {
anim.orbs.forEach((o, i) => {
const a = t * 0.5 + (i / 3) * Math.PI * 2;
o.position.y = 0.25 + i * 0.08 + Math.sin(t * 1.8 + i) * 0.06;
o.position.x = Math.cos(a) * 1.15;
o.position.z = Math.sin(a) * 1.15;
});
}
renderer.render(scene, camera);
};
tick();
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", onPointerEnd);
canvas.removeEventListener("pointercancel", onPointerEnd);
scene.fog = null;
disposeObject3D(group);
scene.remove(group);
renderer.dispose();
};
}, [variant]);
const label = variant === "indie" ? "独立开发互动预览" : "AI Agent 互动预览";
return (
<div ref={containerRef} className="mx-auto mt-2 w-full max-w-2xl">
<canvas
ref={canvasRef}
className="mx-auto block w-full cursor-grab touch-none rounded-2xl shadow-md outline-none active:cursor-grabbing dark:shadow-lg dark:shadow-violet-950/20"
aria-label={label}
role="img"
/>
</div>
);
}