topfans/frontend/components/lenticular/HolographicCard.vue
2026-05-16 02:42:32 +08:00

267 lines
8.8 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>
<view class="holo-container">
<view class="holo-frame" :style="frameStyle">
<!-- WebGL 渲染层 Canvas 缓冲 = CSS × DPRMipmap + 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>