Files
DianZhanDemo/app/pages/PowerStationPipeFlow/index.vue
ch197511161 4f74a80dd8 flow
2025-12-16 19:17:45 +08:00

535 lines
17 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.

<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>