199 lines
7.0 KiB
Vue
199 lines
7.0 KiB
Vue
|
|
<script setup lang="ts">
|
|||
|
|
import type { Material, Mesh } from 'three';
|
|||
|
|
import type { SectionBlock } from '~/types/site';
|
|||
|
|
import { resolveAssetUrl } from '~/utils/resolveAssetUrl';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 3D 模型展示布局(model3d)
|
|||
|
|
*
|
|||
|
|
* config 字段:
|
|||
|
|
* - model_url:GLB 模型路径(素材库上传,.glb/.gltf)
|
|||
|
|
* - model_height:展示区高度 px(默认 480)
|
|||
|
|
* - auto_rotate:是否自动旋转(默认开)
|
|||
|
|
*
|
|||
|
|
* three.js 通过动态 import 懒加载:滚动到视口内才初始化,
|
|||
|
|
* 未命中 3D 区块的页面零开销。渲染器透明背景,跟随区块背景/主题。
|
|||
|
|
*/
|
|||
|
|
const props = defineProps<{ section: SectionBlock }>();
|
|||
|
|
|
|||
|
|
const { overline, title, subtitle, config, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section);
|
|||
|
|
|
|||
|
|
const modelUrl = computed(() => resolveAssetUrl((config.value.model_url as string) || ''));
|
|||
|
|
const viewerHeight = computed(() => {
|
|||
|
|
const h = Number(config.value.model_height);
|
|||
|
|
return `${Number.isFinite(h) && h >= 200 ? h : 480}px`;
|
|||
|
|
});
|
|||
|
|
const autoRotate = computed(() => (config.value.auto_rotate as boolean) !== false);
|
|||
|
|
|
|||
|
|
const containerRef = ref<HTMLDivElement | null>(null);
|
|||
|
|
const status = ref<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
|||
|
|
|
|||
|
|
// 可见后才动态加载 three 并初始化,避免拖慢首屏
|
|||
|
|
const { stop: stopObserve } = useIntersectionObserver(
|
|||
|
|
containerRef,
|
|||
|
|
entries => {
|
|||
|
|
const entry = entries[0];
|
|||
|
|
if (entry?.isIntersecting && status.value === 'idle' && modelUrl.value) {
|
|||
|
|
status.value = 'loading';
|
|||
|
|
stopObserve();
|
|||
|
|
initViewer();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ rootMargin: '200px' }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
async function initViewer() {
|
|||
|
|
if (!containerRef.value || !modelUrl.value) return;
|
|||
|
|
try {
|
|||
|
|
// 动态 import:three 相关代码拆分为独立 chunk,按需加载
|
|||
|
|
const [THREE, { GLTFLoader }, { OrbitControls }] = await Promise.all([
|
|||
|
|
import('three'),
|
|||
|
|
import('three/addons/loaders/GLTFLoader.js'),
|
|||
|
|
import('three/addons/controls/OrbitControls.js')
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const container = containerRef.value;
|
|||
|
|
const width = container.clientWidth;
|
|||
|
|
const height = container.clientHeight;
|
|||
|
|
|
|||
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|||
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|||
|
|
renderer.setSize(width, height);
|
|||
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|||
|
|
container.appendChild(renderer.domElement);
|
|||
|
|
|
|||
|
|
const scene = new THREE.Scene();
|
|||
|
|
|
|||
|
|
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
|||
|
|
|
|||
|
|
// 基础布光:半球光 + 主方向光 + 补光,无环境贴图也能呈现合理质感
|
|||
|
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x333333, 2.2));
|
|||
|
|
const keyLight = new THREE.DirectionalLight(0xffffff, 2.5);
|
|||
|
|
keyLight.position.set(3, 5, 4);
|
|||
|
|
scene.add(keyLight);
|
|||
|
|
const fillLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|||
|
|
fillLight.position.set(-4, 1, -3);
|
|||
|
|
scene.add(fillLight);
|
|||
|
|
|
|||
|
|
const loader = new GLTFLoader();
|
|||
|
|
const gltf = await loader.loadAsync(modelUrl.value);
|
|||
|
|
const model = gltf.scene;
|
|||
|
|
scene.add(model);
|
|||
|
|
|
|||
|
|
// 按包围球自动居中并适配相机距离
|
|||
|
|
const box = new THREE.Box3().setFromObject(model);
|
|||
|
|
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
|||
|
|
const radius = sphere.radius || 1;
|
|||
|
|
model.position.sub(sphere.center);
|
|||
|
|
const dist = radius * 2.8;
|
|||
|
|
camera.position.set(dist * 0.6, dist * 0.4, dist);
|
|||
|
|
camera.near = radius / 100;
|
|||
|
|
camera.far = radius * 100;
|
|||
|
|
camera.updateProjectionMatrix();
|
|||
|
|
|
|||
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|||
|
|
controls.enableDamping = true;
|
|||
|
|
controls.dampingFactor = 0.08;
|
|||
|
|
controls.enablePan = false;
|
|||
|
|
controls.minDistance = radius * 1.4;
|
|||
|
|
controls.maxDistance = radius * 6;
|
|||
|
|
controls.autoRotate = autoRotate.value;
|
|||
|
|
controls.autoRotateSpeed = 1.5;
|
|||
|
|
|
|||
|
|
status.value = 'ready';
|
|||
|
|
|
|||
|
|
let rafId = 0;
|
|||
|
|
const animate = () => {
|
|||
|
|
rafId = requestAnimationFrame(animate);
|
|||
|
|
controls.update();
|
|||
|
|
renderer.render(scene, camera);
|
|||
|
|
};
|
|||
|
|
animate();
|
|||
|
|
|
|||
|
|
// 容器尺寸变化时同步渲染器与相机
|
|||
|
|
const ro = new ResizeObserver(() => {
|
|||
|
|
const w = container.clientWidth;
|
|||
|
|
const h = container.clientHeight;
|
|||
|
|
if (w > 0 && h > 0) {
|
|||
|
|
renderer.setSize(w, h);
|
|||
|
|
camera.aspect = w / h;
|
|||
|
|
camera.updateProjectionMatrix();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
ro.observe(container);
|
|||
|
|
|
|||
|
|
// 记录清理句柄,卸载时释放 WebGL 资源
|
|||
|
|
cleanup = () => {
|
|||
|
|
cancelAnimationFrame(rafId);
|
|||
|
|
ro.disconnect();
|
|||
|
|
controls.dispose();
|
|||
|
|
model.traverse(obj => {
|
|||
|
|
const mesh = obj as Mesh;
|
|||
|
|
if (mesh.isMesh) {
|
|||
|
|
mesh.geometry.dispose();
|
|||
|
|
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
|||
|
|
mats.forEach((m: Material) => m.dispose());
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
renderer.dispose();
|
|||
|
|
renderer.domElement.remove();
|
|||
|
|
};
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[model3d] 3D 模型加载失败', e);
|
|||
|
|
status.value = 'error';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let cleanup: (() => void) | null = null;
|
|||
|
|
onBeforeUnmount(() => {
|
|||
|
|
stopObserve();
|
|||
|
|
cleanup?.();
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="section-container py-[var(--sec-py,96px)]">
|
|||
|
|
<!-- 标题区 -->
|
|||
|
|
<div v-if="overline || title || subtitle" class="text-center max-w-640px mx-auto mb-56px">
|
|||
|
|
<p v-if="overline" class="text-14px font-semibold tracking-widest uppercase" :class="isDark ? 'text-primary-300' : 'text-primary'" :style="overlineStyle">
|
|||
|
|
{{ overline }}
|
|||
|
|
</p>
|
|||
|
|
<h2 class="mt-12px font-bold" :class="isDark ? 'text-white' : 'text-gray-900 dark:text-white'" :style="titleStyle">
|
|||
|
|
{{ title }}
|
|||
|
|
</h2>
|
|||
|
|
<p v-if="subtitle" class="mt-16px text-base" :class="isDark ? 'text-gray-400' : 'text-gray-500 dark:text-gray-400'" :style="bodyStyle">
|
|||
|
|
{{ subtitle }}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 3D 展示区:透明背景跟随区块背景 -->
|
|||
|
|
<div
|
|||
|
|
ref="containerRef"
|
|||
|
|
class="relative w-full overflow-hidden rounded-16px touch-none select-none"
|
|||
|
|
:style="{ height: viewerHeight }"
|
|||
|
|
>
|
|||
|
|
<!-- 加载占位 -->
|
|||
|
|
<div v-if="status !== 'ready'" class="absolute inset-0 flex flex-col items-center justify-center gap-12px">
|
|||
|
|
<template v-if="status === 'error'">
|
|||
|
|
<span class="text-14px" :class="isDark ? 'text-gray-400' : 'text-gray-500'">3D 模型加载失败</span>
|
|||
|
|
</template>
|
|||
|
|
<template v-else-if="!modelUrl">
|
|||
|
|
<span class="text-14px" :class="isDark ? 'text-gray-400' : 'text-gray-500'">未配置 3D 模型</span>
|
|||
|
|
</template>
|
|||
|
|
<template v-else>
|
|||
|
|
<div class="h-24px w-24px animate-spin rounded-full border-2px border-gray-300 border-t-transparent" />
|
|||
|
|
<span class="text-14px" :class="isDark ? 'text-gray-400' : 'text-gray-500'">模型加载中…</span>
|
|||
|
|
</template>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 交互提示 -->
|
|||
|
|
<div
|
|||
|
|
v-if="status === 'ready'"
|
|||
|
|
class="absolute bottom-12px left-1/2 -translate-x-1/2 rounded-full bg-black/40 px-12px py-4px text-12px text-white backdrop-blur"
|
|||
|
|
>
|
|||
|
|
拖拽旋转 · 滚轮缩放
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|