|
|
@@ -0,0 +1,319 @@
|
|
|
+<template>
|
|
|
+ <div ref="container" class="model-container">
|
|
|
+ <div v-if="showProgress" class="loading-overlay">
|
|
|
+ <div class="loading-box">
|
|
|
+ <div class="bar">
|
|
|
+ <div class="fill" :style="{ width: progress + '%' }"></div>
|
|
|
+ </div>
|
|
|
+ <p class="percent">{{ progress }}%</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, onMounted, onUnmounted } from "vue";
|
|
|
+import * as THREE from "three";
|
|
|
+import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader";
|
|
|
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
|
|
|
+import { TagModel } from "../TagModel";
|
|
|
+import TagChonger from "../images/img_chonger.png";
|
|
|
+import TagGuanwu from "../images/img_guanwu.png";
|
|
|
+import TagJinchi from "../images/img_jinchi.png";
|
|
|
+import TagJinzan from "../images/img_jinzan.png";
|
|
|
+import TagLiu from "../images/img_liu.png";
|
|
|
+import TagYanban from "../images/img_yanban.png";
|
|
|
+import TagYuheng from "../images/img_yuheng.png";
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ activeName: { type: String, default: "" },
|
|
|
+});
|
|
|
+const container = ref(null);
|
|
|
+
|
|
|
+const progress = ref(0);
|
|
|
+const showProgress = ref(true);
|
|
|
+const tagArr = [];
|
|
|
+
|
|
|
+let renderer, scene, camera, controls, mixer, clock, rafId;
|
|
|
+// 存放需要做呼吸灯效果的 mesh(冠武对应 fbx.children[0] 下的所有网格)
|
|
|
+let breathMeshes = [];
|
|
|
+
|
|
|
+// Scale and controls limits
|
|
|
+const INITIAL_SCALE_MULTIPLIER = 1.6;
|
|
|
+const MIN_SCALE = 0.5; // minimum model scale
|
|
|
+const MAX_SCALE = 4.0; // maximum model scale
|
|
|
+const CONTROLS_MIN_DISTANCE = 80; // min zoom distance
|
|
|
+const CONTROLS_MAX_DISTANCE = 900; // max zoom distance
|
|
|
+const MESH_MAP = {
|
|
|
+ 冠武: [0],
|
|
|
+ 九旒: [4],
|
|
|
+ 綖板: [3],
|
|
|
+};
|
|
|
+
|
|
|
+function onWindowResize() {
|
|
|
+ if (!container.value || !renderer || !camera) return;
|
|
|
+ const w = container.value.clientWidth;
|
|
|
+ const h = container.value.clientHeight;
|
|
|
+ camera.aspect = w / h;
|
|
|
+ camera.updateProjectionMatrix();
|
|
|
+ renderer.setSize(w, h);
|
|
|
+}
|
|
|
+
|
|
|
+const handleTag = (visible) => {
|
|
|
+ if (!tagArr.length) {
|
|
|
+ const temp = [];
|
|
|
+ const tagStack = [
|
|
|
+ {
|
|
|
+ url: TagChonger,
|
|
|
+ width: 64,
|
|
|
+ height: 223,
|
|
|
+ position: new THREE.Vector3(35, -33, 7),
|
|
|
+ scale: 0.2,
|
|
|
+ rotation: { x: 0, y: 0, z: 0 },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ url: TagGuanwu,
|
|
|
+ width: 64,
|
|
|
+ height: 223,
|
|
|
+ position: new THREE.Vector3(23, -40, 20),
|
|
|
+ scale: 0.2,
|
|
|
+ rotation: { x: 0, y: 0, z: 0 },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ url: TagJinchi,
|
|
|
+ width: 64,
|
|
|
+ height: 320,
|
|
|
+ position: new THREE.Vector3(0, -43, 30),
|
|
|
+ scale: 0.2,
|
|
|
+ rotation: { x: 0, y: 0, z: 0 },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ url: TagJinzan,
|
|
|
+ width: 216,
|
|
|
+ height: 56,
|
|
|
+ position: new THREE.Vector3(67, 16, 5),
|
|
|
+ scale: 0.2,
|
|
|
+ rotation: { x: 0, y: 0, z: 0 },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ url: TagLiu,
|
|
|
+ width: 101,
|
|
|
+ height: 56,
|
|
|
+ position: new THREE.Vector3(-45, -3, 71),
|
|
|
+ scale: 0.2,
|
|
|
+ rotation: { x: 0, y: 0, z: 0 },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ url: TagYanban,
|
|
|
+ width: 64,
|
|
|
+ height: 139,
|
|
|
+ position: new THREE.Vector3(0, 47, 30),
|
|
|
+ scale: 0.2,
|
|
|
+ rotation: { x: 0, y: 0, z: 0 },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ url: TagYuheng,
|
|
|
+ width: 64,
|
|
|
+ height: 139,
|
|
|
+ position: new THREE.Vector3(35, 43, 5),
|
|
|
+ scale: 0.2,
|
|
|
+ rotation: { x: 0, y: 0, z: 0 },
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ tagStack.forEach((tag) => {
|
|
|
+ const tagIns = new TagModel(tag);
|
|
|
+
|
|
|
+ temp.push(tagIns);
|
|
|
+ scene?.add(tagIns.mesh);
|
|
|
+ });
|
|
|
+
|
|
|
+ tagArr.push(...temp);
|
|
|
+ }
|
|
|
+
|
|
|
+ tagArr.forEach((tag) => {
|
|
|
+ visible ? tag.show() : tag.hide();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ clock = new THREE.Clock();
|
|
|
+
|
|
|
+ scene = new THREE.Scene();
|
|
|
+
|
|
|
+ const w = container.value.clientWidth || 400;
|
|
|
+ const h = container.value.clientHeight || 300;
|
|
|
+ camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 2000);
|
|
|
+ camera.position.set(160, 60, 300);
|
|
|
+
|
|
|
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
|
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
|
+ renderer.setSize(w, h);
|
|
|
+ renderer.outputEncoding = THREE.sRGBEncoding;
|
|
|
+ container.value.appendChild(renderer.domElement);
|
|
|
+
|
|
|
+ const ambient = new THREE.AmbientLight(0xffffff, 1.0);
|
|
|
+ scene.add(ambient);
|
|
|
+
|
|
|
+ const dir = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
|
+ dir.position.set(100, 200, 100);
|
|
|
+ scene.add(dir);
|
|
|
+
|
|
|
+ controls = new OrbitControls(camera, renderer.domElement);
|
|
|
+ controls.enableDamping = true;
|
|
|
+ controls.target.set(0, 0, 0);
|
|
|
+ controls.minDistance = CONTROLS_MIN_DISTANCE;
|
|
|
+ controls.maxDistance = CONTROLS_MAX_DISTANCE;
|
|
|
+
|
|
|
+ const loader = new FBXLoader();
|
|
|
+ loader.load(
|
|
|
+ encodeURI("./model/13.398九旒冕.fbx"),
|
|
|
+ (fbx) => {
|
|
|
+ fbx.traverse((c) => {
|
|
|
+ if (c.isMesh) c.castShadow = true;
|
|
|
+ });
|
|
|
+
|
|
|
+ fbx.position.set(0, 0, 0);
|
|
|
+ fbx.rotation.set(0, 0, 0);
|
|
|
+ const box = new THREE.Box3().setFromObject(fbx);
|
|
|
+ const size = box.getSize(new THREE.Vector3()).length();
|
|
|
+ if (size > 0) {
|
|
|
+ const scale = 120 / size;
|
|
|
+ // apply an initial multiplier then clamp to allowed range
|
|
|
+ const target = Math.max(
|
|
|
+ MIN_SCALE,
|
|
|
+ Math.min(MAX_SCALE, scale * INITIAL_SCALE_MULTIPLIER),
|
|
|
+ );
|
|
|
+ fbx.scale.setScalar(target);
|
|
|
+ }
|
|
|
+
|
|
|
+ breathMeshes.push(fbx.children[0]);
|
|
|
+
|
|
|
+ scene.add(fbx);
|
|
|
+
|
|
|
+ if (fbx.animations && fbx.animations.length) {
|
|
|
+ mixer = new THREE.AnimationMixer(fbx);
|
|
|
+ fbx.animations.forEach((clip) => mixer.clipAction(clip).play());
|
|
|
+ }
|
|
|
+
|
|
|
+ progress.value = 100;
|
|
|
+ // hide after a short delay so user sees 100%
|
|
|
+ setTimeout(() => {
|
|
|
+ showProgress.value = false;
|
|
|
+ handleTag(true);
|
|
|
+ }, 400);
|
|
|
+ },
|
|
|
+ // onProgress
|
|
|
+ (xhr) => {
|
|
|
+ if (xhr && xhr.lengthComputable) {
|
|
|
+ const p = Math.round((xhr.loaded / xhr.total) * 100);
|
|
|
+ progress.value = Math.min(100, Math.max(0, p));
|
|
|
+ }
|
|
|
+ },
|
|
|
+ // onError
|
|
|
+ (err) => {
|
|
|
+ console.error("FBX load error", err);
|
|
|
+ showProgress.value = false;
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ function animate() {
|
|
|
+ const delta = clock.getDelta();
|
|
|
+ if (mixer) mixer.update(delta);
|
|
|
+
|
|
|
+ // 呼吸灯效果
|
|
|
+ if (breathMeshes && breathMeshes.length) {
|
|
|
+ const t = clock.getElapsedTime();
|
|
|
+
|
|
|
+ breathMeshes.forEach((mesh) => {
|
|
|
+ let materials = mesh.material;
|
|
|
+ if (!materials) return;
|
|
|
+ if (!Array.isArray(materials)) materials = [materials];
|
|
|
+
|
|
|
+ materials.forEach((mat, index) => {
|
|
|
+ if (!mat) return;
|
|
|
+ // 使用 emissive/emissiveIntensity 做发光呼吸
|
|
|
+ if (!mat.emissive) {
|
|
|
+ mat.emissive = new THREE.Color(0x000000);
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ Array.isArray(MESH_MAP[props.activeName]) &&
|
|
|
+ MESH_MAP[props.activeName].includes(index)
|
|
|
+ ) {
|
|
|
+ mat.emissive.setHex(0xffcc66);
|
|
|
+ mat.emissiveIntensity = 0.4 + 0.3 * Math.sin(t * 2.5);
|
|
|
+ } else {
|
|
|
+ mat.emissiveIntensity = 0;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ controls.update();
|
|
|
+ renderer.render(scene, camera);
|
|
|
+ rafId = requestAnimationFrame(animate);
|
|
|
+ }
|
|
|
+
|
|
|
+ animate();
|
|
|
+ window.addEventListener("resize", onWindowResize);
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ window.removeEventListener("resize", onWindowResize);
|
|
|
+ if (rafId) cancelAnimationFrame(rafId);
|
|
|
+ if (controls) controls.dispose();
|
|
|
+ if (mixer) mixer.stopAllAction();
|
|
|
+ if (renderer) {
|
|
|
+ renderer.forceContextLoss();
|
|
|
+ const canvas = renderer.domElement;
|
|
|
+ if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
|
|
+ renderer.dispose();
|
|
|
+ }
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.model-container {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: block;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.loading-overlay {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: rgba(0, 0, 0, 0.35);
|
|
|
+ z-index: 40;
|
|
|
+}
|
|
|
+
|
|
|
+.loading-box {
|
|
|
+ width: 70%;
|
|
|
+ max-width: 420px;
|
|
|
+ text-align: center;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+
|
|
|
+.bar {
|
|
|
+ height: 10px;
|
|
|
+ width: 100%;
|
|
|
+ background: rgba(255, 255, 255, 0.15);
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.fill {
|
|
|
+ height: 100%;
|
|
|
+ background: linear-gradient(90deg, #ffd37a, #ff9e5a);
|
|
|
+ width: 0%;
|
|
|
+ transition: width 120ms linear;
|
|
|
+}
|
|
|
+
|
|
|
+.percent {
|
|
|
+ margin-top: 8px;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+</style>
|