Files
DianZhanDemo/app/pages/PowerStationPipeFlow/index.vue

535 lines
17 KiB
Vue
Raw Normal View History

2025-12-16 19:17:45 +08:00
<template>
<div class="relative w-full h-screen overflow-hidden bg-gray-900">
<!-- Three.js 容器 -->
<div
ref="containerRef"
class="w-full h-full outline-none"
tabindex="0"
@click="focusContainer"
/>
<!-- UI 面板 -->
<div
class="absolute top-4 right-4 z-10 w-80 bg-slate-800/90 backdrop-blur-md rounded-lg shadow-xl border border-slate-700 p-4 text-gray-200"
>
<h3 class="text-lg font-bold mb-4 text-cyan-400 flex items-center justify-between">
<span>控制面板</span>
<el-tag size="small" type="info">WASDQE移动 + 鼠标拖动</el-tag>
</h3>
<div class="space-y-4">
<!-- 模型加载 -->
<div class="border-b border-gray-700 pb-4">
<div class="font-semibold mb-2 text-sm text-gray-400">模型加载</div>
<div class="grid grid-cols-2 gap-2">
<el-button type="primary" size="small" @click="resetToDefaultModel">重置默认</el-button>
<el-upload
action=""
:auto-upload="false"
:show-file-list="false"
accept=".glb,.gltf"
:on-change="handleFileChange"
>
<el-button type="success" size="small" class="w-full">加载本地GLB</el-button>
</el-upload>
</div>
</div>
<!-- 材质控制 -->
<div v-if="targetMaterial" class="space-y-3">
<div class="font-semibold text-sm text-cyan-400">材质: {{ targetMaterial.name }}</div>
<!-- 基础颜色 -->
<div class="flex items-center justify-between">
<span class="text-sm">基础颜色 (BaseColor)</span>
<el-color-picker
v-model="materialParams.color"
size="small"
show-alpha
@change="updateMaterialColor"
/>
</div>
<!-- Alpha 贴图 -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">Alpha贴图</span>
<el-upload
action=""
:auto-upload="false"
:show-file-list="false"
accept="image/*"
:on-change="handleAlphaMapChange"
>
<el-button link type="primary" size="small">更换贴图</el-button>
</el-upload>
</div>
<div class="space-y-1">
<div class="flex justify-between text-xs text-gray-500">
<span>U重复 (Tiling U)</span>
<span>{{ materialParams.tilingU.toFixed(2) }}</span>
</div>
<el-slider
v-model="materialParams.tilingU"
:min="0"
:max="10"
:step="0.3"
@input="updateMaterialUniforms"
/>
</div>
<!-- U方向翻转 -->
<div class="flex items-center justify-between">
<span class="text-sm">U方向翻转 (Flip U)</span>
<el-switch
v-model="materialParams.flipU"
size="small"
@change="updateMaterialUniforms"
/>
</div>
</div>
<!-- 动画控制 -->
<div class="pt-2 border-t border-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">动画控制</span>
<el-switch v-model="animationParams.isPlaying" size="small" active-text="流动" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs whitespace-nowrap">速度倍率:</span>
<el-input-number
v-model="animationParams.speedMultiplier"
size="small"
:step="0.3"
class="w-full"
/>
</div>
</div>
</div>
<div v-else class="text-center py-4 text-gray-500 text-sm italic">
未找到名为 "fx" 的材质
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { onMounted, onUnmounted, reactive, ref } from 'vue'
// --- 状态管理 ---
const containerRef = ref<HTMLDivElement>()
const targetMaterial = ref<THREE.MeshStandardMaterial | null>(null)
const materialParams = reactive({
color: '#ffffff',
tilingU: 1,
flipU: false,
})
const animationParams = reactive({
isPlaying: true,
speedMultiplier: 1.0,
})
// --- Three.js 变量 ---
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let renderer: THREE.WebGLRenderer
let controls: OrbitControls
let gltfloader: GLTFLoader
let clock: THREE.Clock
let animationFrameId: number
let mixer: THREE.AnimationMixer
// 按键状态
const keys = { w: false, a: false, s: false, d: false, q: false, e: false }
const moveSpeed = 10.0 // 每秒移动单位数
// --- 生命周期 ---
onMounted(() => {
initThree()
setupInputs()
loadDefaultModel()
animate()
window.addEventListener('resize', onWindowResize)
})
onUnmounted(() => {
window.removeEventListener('resize', onWindowResize)
cancelAnimationFrame(animationFrameId)
renderer?.dispose()
controls?.dispose()
})
// --- 初始化 ---
function initThree() {
if (!containerRef.value) return
// 场景
scene = new THREE.Scene()
scene.background = new THREE.Color(0x111111)
// 网格辅助线
const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x222222)
scene.add(gridHelper)
// 坐标轴辅助线
const axesHelper = new THREE.AxesHelper(5)
scene.add(axesHelper)
// 灯光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)
scene.add(ambientLight)
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2)
dirLight.position.set(10, 20, 10)
dirLight.castShadow = true
scene.add(dirLight)
// 相机参数
const width = containerRef.value.clientWidth
const height = containerRef.value.clientHeight
camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000)
camera.position.set(5, 5, 5)
// 渲染器
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.shadowMap.enabled = true
containerRef.value.appendChild(renderer.domElement)
// 控制器 - OrbitControls + 自定义平移
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.screenSpacePanning = false
controls.minDistance = 0.1
controls.maxDistance = 500
controls.maxPolarAngle = Math.PI // 完全旋转
// 加载器
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/')
gltfloader = new GLTFLoader()
gltfloader.setDRACOLoader(dracoLoader)
clock = new THREE.Clock()
}
function setupInputs() {
window.addEventListener('keydown', e => {
const key = e.key.toLowerCase()
if (key in keys) keys[key as keyof typeof keys] = true
})
window.addEventListener('keyup', e => {
const key = e.key.toLowerCase()
if (key in keys) keys[key as keyof typeof keys] = false
})
}
function focusContainer() {
containerRef.value?.focus()
}
// --- 模型逻辑 ---
const DEFAULT_MODEL_URL = '/PowerStationPipeFlow/FlowDemo.glb'
let currentModel: THREE.Group | null = null
function cleanCurrentModel() {
if (currentModel) {
scene.remove(currentModel)
currentModel.traverse(child => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
mesh.geometry?.dispose()
if (Array.isArray(mesh.material)) {
mesh.material.forEach(m => m.dispose())
} else if (mesh.material) {
mesh.material.dispose()
}
}
})
currentModel = null
targetMaterial.value = null
}
}
function loadDefaultModel() {
cleanCurrentModel()
loadGlb(DEFAULT_MODEL_URL)
}
function handleFileChange(uploadFile: any) {
if (!uploadFile.raw) return
const url = URL.createObjectURL(uploadFile.raw)
cleanCurrentModel()
loadGlb(url)
}
function loadGlb(url: string) {
const loadingMsg = ElMessage.info({ message: '加载模型中...', duration: 0 })
gltfloader.load(
url,
gltf => {
loadingMsg.close()
ElMessage.success('模型加载成功')
currentModel = gltf.scene as THREE.Group
scene.add(currentModel)
// 处理材质
currentModel.traverse(child => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
// 启用阴影
mesh.castShadow = true
mesh.receiveShadow = true
if (mesh.material) {
const mat = mesh.material as THREE.MeshStandardMaterial
if (mat.name === 'fx') {
targetMaterial.value = mat
// 加载并应用箭头纹理作为Alpha贴图
const textureLoader = new THREE.TextureLoader()
textureLoader.load(
'/PowerStationPipeFlow/arrow.png',
tex => {
tex.wrapS = THREE.RepeatWrapping
tex.wrapT = THREE.RepeatWrapping
mat.alphaMap = tex
mat.transparent = true
mat.alphaTest = 0.5 // Alpha模式遮罩
mat.needsUpdate = true
// 同步参数
materialParams.tilingU = tex.repeat.x
},
undefined,
error => {
console.error('加载Alpha贴图失败:', error)
ElMessage.error('Alpha贴图加载失败')
}
)
// 从材质初始化参数
materialParams.color = '#' + mat.color.getHexString()
// 自发光80%
mat.emissive.copy(mat.color)
mat.emissiveIntensity = 0.8
}
}
}
})
// 自动最大化(适应视图)
fitCameraToSelection(camera, controls, [currentModel], 1.2)
},
// 加载进度回调
progress => {
const percent = (progress.loaded / progress.total) * 100
loadingMsg.message = `加载模型中... ${percent.toFixed(1)}%`
},
err => {
loadingMsg.close()
console.error('模型加载失败:', err)
ElMessage.error('加载模型失败')
}
)
}
function fitCameraToSelection(
camera: THREE.PerspectiveCamera,
controls: OrbitControls,
selection: THREE.Object3D[],
fitOffset = 1.2
) {
const box = new THREE.Box3()
for (const object of selection) box.expandByObject(object)
if (box.isEmpty()) return
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
// 最大尺寸
const maxDim = Math.max(size.x, size.y, size.z)
const fov = camera.fov * (Math.PI / 180)
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2))
cameraZ *= fitOffset // 稍微拉远,让物体不会填满屏幕
// 更新控制器
controls.target.copy(center)
// 重新定位相机
// 尝试保持当前方向但改变距离
const direction = camera.position.clone().sub(controls.target).normalize()
if (direction.lengthSq() < 0.001) direction.set(0, 0, 1) // 如果已经在中心,使用后备方案
const newPos = center.clone().add(direction.multiplyScalar(cameraZ))
camera.position.copy(newPos)
camera.updateProjectionMatrix()
controls.update()
}
// --- 动画和控制 ---
function updateMovement(delta: number) {
if (!controls) return
// 根据相机方向计算移动向量
const moveVec = new THREE.Vector3()
if (keys.w) moveVec.z -= 1
if (keys.s) moveVec.z += 1
if (keys.a) moveVec.x -= 1
if (keys.d) moveVec.x += 1
if (keys.q) moveVec.y += 1 // 向上
if (keys.e) moveVec.y -= 1 // 向下
if (moveVec.lengthSq() > 0) {
moveVec.normalize().multiplyScalar(moveSpeed * delta)
// 获取相机方向向量
const forward = new THREE.Vector3()
camera.getWorldDirection(forward)
// 我们希望W/S通常在XZ平面上移动还是沿着相机视线
// "wasdqe"通常 -> W=前进S=后退A=左移D=右移相对于视角
// Q/E = 世界空间垂直上下
// 分解前向向量以移除Y分量如果需要"行走"模式),但飞行模式保持它
// 让我们为W/S/A/D实现完整的"飞行"模式,相对于相机方向
const right = new THREE.Vector3().crossVectors(forward, camera.up).normalize()
// 基于相机基础重新计算移动
const activeMove = new THREE.Vector3()
// 前进/后退
activeMove.add(forward.clone().multiplyScalar(-moveVec.z)) // -z在典型控制映射中是前进如果我们为W使用z-=1
// 平移
activeMove.add(right.clone().multiplyScalar(moveVec.x))
// 上/下世界空间还是相机空间通常Q/E使用世界空间
// 前面的逻辑在moveVec.y中放入了+/-1用于Q/E
// 让我们为世界坐标的上方向应用Q/E
activeMove.y += -moveVec.y // Q是y+=1但camera.up通常是+Y
// 但是等等moveVec.z由W/S设置。在我的映射中W -> z -= 1
// 标准Three.js相机朝向-Z。所以W前进应该启用朝向-Z本地的移动
// 让我们细化:
const actualMove = new THREE.Vector3(0, 0, 0)
// 前进/后退(相机方向)
if (keys.w) actualMove.add(forward)
if (keys.s) actualMove.sub(forward)
// 左/右(相机右方向)
if (keys.d) actualMove.add(right)
if (keys.a) actualMove.sub(right)
actualMove.normalize().multiplyScalar(moveSpeed * delta)
// 上/下(世界上方向)
if (keys.q) actualMove.y += moveSpeed * delta
if (keys.e) actualMove.y -= moveSpeed * delta
// 应用到相机和控制目标,严格移动"装备"
camera.position.add(actualMove)
controls.target.add(actualMove)
}
}
function animate() {
animationFrameId = requestAnimationFrame(animate)
const delta = clock.getDelta()
controls.update()
updateMovement(delta)
// 材质动画 - 只在需要时更新
if (animationParams.isPlaying && targetMaterial.value?.alphaMap) {
const map = targetMaterial.value.alphaMap
map.offset.x += delta * 0.1 * animationParams.speedMultiplier
// 标记需要更新
map.needsUpdate = true
}
renderer.render(scene, camera)
}
function onWindowResize() {
if (!containerRef.value) return
const width = containerRef.value.clientWidth
const height = containerRef.value.clientHeight
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height)
}
// --- 材质辅助函数 ---
function updateMaterialColor(val: string) {
if (targetMaterial.value) {
targetMaterial.value.color.set(val)
targetMaterial.value.emissive.set(val)
}
}
function updateMaterialUniforms() {
if (targetMaterial.value && targetMaterial.value.alphaMap) {
const tiling = materialParams.tilingU
targetMaterial.value.alphaMap.repeat.x = materialParams.flipU ? -tiling : tiling
}
}
function handleAlphaMapChange(uploadFile: any) {
if (!targetMaterial.value || !uploadFile.raw) return
const url = URL.createObjectURL(uploadFile.raw)
const textureLoader = new THREE.TextureLoader()
textureLoader.load(url, tex => {
tex.wrapS = THREE.RepeatWrapping
tex.wrapT = THREE.RepeatWrapping
// 保持旧的设置
const oldOff = targetMaterial.value!.alphaMap?.offset.clone() || new THREE.Vector2(0, 0)
const oldRep = targetMaterial.value!.alphaMap?.repeat.clone() || new THREE.Vector2(1, 1)
targetMaterial.value!.alphaMap = tex
targetMaterial.value!.alphaMap.offset = oldOff
targetMaterial.value!.alphaMap.repeat = oldRep
targetMaterial.value!.needsUpdate = true
materialParams.tilingU = tex.repeat.x
})
}
// 重置默认模型
function resetToDefaultModel() {
loadDefaultModel()
}
</script>
<style scoped>
:focus {
outline: none;
}
</style>