535 lines
17 KiB
Vue
535 lines
17 KiB
Vue
|
|
<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>
|