Files
admin/www/app/components/section/layouts/LayoutModel3d.vue
T
2026-08-17 14:58:02 +08:00

199 lines
7.0 KiB
Vue
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.
<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_urlGLB 模型路径(素材库上传,.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 {
// 动态 importthree 相关代码拆分为独立 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>