267 lines
8.8 KiB
Vue
267 lines
8.8 KiB
Vue
<template>
|
||
<view class="holo-container">
|
||
<view class="holo-frame" :style="frameStyle">
|
||
<!-- WebGL 渲染层 —— Canvas 缓冲 = CSS × DPR,Mipmap + MSAA -->
|
||
<canvas
|
||
v-show="webglReady && !hasError"
|
||
ref="webglCanvas"
|
||
class="holo-canvas"
|
||
:style="canvasStyle"
|
||
@touchstart.stop="onTouchStart"
|
||
@touchmove.stop.prevent="onTouchMove"
|
||
@touchend.stop="onTouchEnd"
|
||
@touchcancel.stop="onTouchEnd"
|
||
/>
|
||
|
||
<!-- CSS 降级层(WebGL 不可用时自动启用) -->
|
||
<view v-if="!webglReady || hasError" class="holo-fallback" :style="fallbackStyle">
|
||
<view class="holo-fallback-card" :style="fallbackCardStyle">
|
||
<image
|
||
v-if="baseImageSrc"
|
||
class="holo-fallback-img"
|
||
:src="baseImageSrc"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="holo-fallback-shimmer" />
|
||
<view class="holo-fallback-rim" />
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="showHint && hintText" class="holo-hint" :class="{ 'holo-hint--hidden': !showHint }">
|
||
<text class="holo-hint-icon">↻</text>
|
||
<text class="holo-hint-text">{{ hintText }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||
import { HolographicEngine } from '@/utils/webgl/holographic-engine.js'
|
||
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
|
||
|
||
const props = defineProps({
|
||
baseImageSrc: { type: String, default: '' },
|
||
aspectRatio: { type: Number, default: 3 / 4 },
|
||
maxWidth: { type: Number, default: 420 },
|
||
cornerRadius: { type: Number, default: 24 },
|
||
effectIntensity: { type: Number, default: 0.85 },
|
||
dispersionStrength: { type: Number, default: 1.0 },
|
||
diffractionScale: { type: Number, default: 0.7 },
|
||
highlightSpeed: { type: Number, default: 0.8 },
|
||
highlightWidth: { type: Number, default: 1.0 },
|
||
fresnelPower: { type: Number, default: 3.5 },
|
||
noiseScale: { type: Number, default: 1.0 },
|
||
noiseOctaves: { type: Number, default: 6 },
|
||
safeZoneRadius: { type: Number, default: 0.35 },
|
||
safeZoneSoftness: { type: Number, default: 0.15 },
|
||
viewX: { type: Number, default: 0 },
|
||
viewY: { type: Number, default: 0 },
|
||
viewZ: { type: Number, default: 0 },
|
||
hintText: { type: String, default: '移动光标或倾斜设备' },
|
||
showHint: { type: Boolean, default: true },
|
||
maxDPR: { type: Number, default: 2 },
|
||
skipBuiltInTouch: { type: Boolean, default: false },
|
||
})
|
||
|
||
const emit = defineEmits(['simulate', 'ready', 'error', 'fpsUpdate'])
|
||
|
||
const webglCanvas = ref(null)
|
||
const webglReady = ref(false)
|
||
const hasError = ref(false)
|
||
let engine = null
|
||
let resizeObserver = null
|
||
let fpsInterval = null
|
||
|
||
function resolveSize() {
|
||
const vw = typeof window !== 'undefined' ? window.innerWidth : 375
|
||
const w = Math.min(props.maxWidth, vw * 0.92)
|
||
const h = w / props.aspectRatio
|
||
return { w, h }
|
||
}
|
||
|
||
const frameStyle = computed(() => {
|
||
const { w, h } = resolveSize()
|
||
return { width: `${w}px`, height: `${h}px` }
|
||
})
|
||
const canvasStyle = computed(() => {
|
||
const { w, h } = resolveSize()
|
||
return { width: `${w}px`, height: `${h}px` }
|
||
})
|
||
const fallbackStyle = computed(() => {
|
||
const { w, h } = resolveSize()
|
||
return { width: `${w}px`, height: `${h}px` }
|
||
})
|
||
const fallbackCardStyle = computed(() => {
|
||
const vx = props.viewX || 0
|
||
const vy = props.viewY || 0
|
||
return {
|
||
borderRadius: `${props.cornerRadius}px`,
|
||
transform: `perspective(1000px) rotateY(${vx * 15}deg) rotateX(${-vy * 8}deg)`,
|
||
}
|
||
})
|
||
|
||
function dispatchTouch(clientX, clientY) {
|
||
if (props.skipBuiltInTouch) return
|
||
const canvas = webglCanvas.value
|
||
if (!canvas) return
|
||
const rect = canvas.getBoundingClientRect()
|
||
if (!rect.width || !rect.height) return
|
||
const cx = rect.left + rect.width / 2
|
||
const cy = rect.top + rect.height / 2
|
||
emit('simulate',
|
||
Math.max(-1, Math.min(1, (clientX - cx) / (rect.width / 2) * 1.1)),
|
||
Math.max(-1, Math.min(1, (clientY - cy) / (rect.height / 2) * 1.1))
|
||
)
|
||
}
|
||
|
||
function getT(e) {
|
||
if (e.touches && e.touches[0]) return e.touches[0]
|
||
if (e.changedTouches && e.changedTouches[0]) return e.changedTouches[0]
|
||
return null
|
||
}
|
||
function onTouchStart(e) { onTouchMove(e) }
|
||
function onTouchMove(e) {
|
||
if (props.skipBuiltInTouch) return
|
||
const t = getT(e)
|
||
if (t) dispatchTouch(t.clientX, t.clientY)
|
||
}
|
||
function onTouchEnd() {
|
||
if (props.skipBuiltInTouch) return
|
||
emit('simulate', 0, 0)
|
||
}
|
||
|
||
function syncEngineParams() {
|
||
if (!engine) return
|
||
engine.setEffectParams({
|
||
effectIntensity: props.effectIntensity,
|
||
dispersionStrength: props.dispersionStrength,
|
||
diffractionScale: props.diffractionScale,
|
||
highlightSpeed: props.highlightSpeed,
|
||
highlightWidth: props.highlightWidth,
|
||
fresnelPower: props.fresnelPower,
|
||
noiseScale: props.noiseScale,
|
||
noiseOctaves: Math.round(props.noiseOctaves),
|
||
cardCornerRadius: props.cornerRadius,
|
||
safeZoneRadius: props.safeZoneRadius,
|
||
safeZoneSoftness: props.safeZoneSoftness,
|
||
})
|
||
engine.setViewAngle(props.viewX, props.viewY, props.viewZ)
|
||
}
|
||
|
||
watch(() => [
|
||
props.effectIntensity, props.dispersionStrength, props.diffractionScale,
|
||
props.highlightSpeed, props.highlightWidth, props.fresnelPower,
|
||
props.noiseScale, props.noiseOctaves, props.cornerRadius,
|
||
props.safeZoneRadius, props.safeZoneSoftness,
|
||
props.viewX, props.viewY, props.viewZ,
|
||
], syncEngineParams)
|
||
|
||
watch(() => props.baseImageSrc, async (src) => {
|
||
if (!engine || !src) return
|
||
try {
|
||
const img = await loadTextureImage(src)
|
||
engine.uploadBaseImage(img)
|
||
} catch (e) {
|
||
console.warn('[HolographicCard] load baseImage failed:', e)
|
||
}
|
||
})
|
||
|
||
function initWebGL() {
|
||
if (!HolographicEngine.isSupported()) {
|
||
hasError.value = true
|
||
emit('error', new Error('WebGL not supported'))
|
||
return
|
||
}
|
||
const canvas = webglCanvas.value
|
||
if (!canvas) return
|
||
try {
|
||
const { w, h } = resolveSize()
|
||
engine = new HolographicEngine(canvas, {
|
||
alpha: true,
|
||
antialias: true,
|
||
useFBO: false,
|
||
})
|
||
engine.setDPR(Math.min(window.devicePixelRatio || 1, props.maxDPR))
|
||
if (!engine.init()) {
|
||
hasError.value = true
|
||
emit('error', new Error('WebGL init failed'))
|
||
return
|
||
}
|
||
syncEngineParams()
|
||
engine.start()
|
||
webglReady.value = true
|
||
emit('ready')
|
||
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
resizeObserver = new ResizeObserver((entries) => {
|
||
for (const e of entries) {
|
||
const { width, height } = e.contentRect
|
||
if (engine && width > 0 && height > 0) engine.resize(width, height)
|
||
}
|
||
})
|
||
resizeObserver.observe(canvas.parentElement || canvas)
|
||
}
|
||
|
||
fpsInterval = setInterval(() => {
|
||
if (engine) emit('fpsUpdate', engine.getFPS())
|
||
}, 1000)
|
||
|
||
if (props.baseImageSrc) {
|
||
loadTextureImage(props.baseImageSrc)
|
||
.then(img => engine.uploadBaseImage(img))
|
||
.catch(e => console.warn('[HolographicCard] init load:', e))
|
||
}
|
||
} catch (e) {
|
||
console.error('[HolographicCard] init error:', e)
|
||
hasError.value = true
|
||
emit('error', e)
|
||
}
|
||
}
|
||
|
||
onMounted(() => { nextTick(initWebGL) })
|
||
|
||
onUnmounted(() => {
|
||
if (fpsInterval) { clearInterval(fpsInterval); fpsInterval = null }
|
||
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null }
|
||
if (engine) { engine.destroy(); engine = null }
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.holo-container { width: 100%; display: flex; align-items: center; justify-content: center; }
|
||
.holo-frame { position: relative; max-width: 100%; }
|
||
.holo-canvas { display: block; border-radius: inherit; }
|
||
|
||
/* ---- CSS 降级 ---- */
|
||
.holo-fallback { position: relative; display: flex; align-items: center; justify-content: center; perspective: 1000px; }
|
||
.holo-fallback-card {
|
||
width: 100%; height: 100%; overflow: hidden; position: relative;
|
||
background-color: #060e20; box-shadow: 0 20px 50px rgba(0,0,0,0.55);
|
||
border: 1px solid rgba(255,255,255,0.18);
|
||
will-change: transform; transform-style: preserve-3d;
|
||
}
|
||
.holo-fallback-img { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
|
||
.holo-fallback-shimmer {
|
||
position: absolute; inset: 0; pointer-events: none;
|
||
background: linear-gradient(135deg, rgba(221,183,255,0.08) 0%, rgba(100,200,255,0.15) 25%, transparent 50%, rgba(255,180,220,0.12) 75%, rgba(76,215,246,0.08) 100%);
|
||
}
|
||
.holo-fallback-rim {
|
||
position: absolute; inset: 0;
|
||
border-top: 1px solid rgba(221,183,255,0.42);
|
||
box-shadow: inset 0 0 40px rgba(255,255,255,0.05);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ---- 提示 ---- */
|
||
.holo-hint {
|
||
position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
|
||
display: flex; align-items: center; gap: 6px;
|
||
padding: 8px 16px; background: rgba(0,0,0,0.55); backdrop-filter: blur(12px);
|
||
border-radius: 20px; transition: opacity 0.4s ease; pointer-events: none;
|
||
}
|
||
.holo-hint--hidden { opacity: 0; }
|
||
.holo-hint-icon { font-size: 16px; color: rgba(255,255,255,0.8); }
|
||
.holo-hint-text { font-size: 12px; color: rgba(255,255,255,0.75); }
|
||
</style>
|