topfans/frontend/components/laser/LaserPreviewCanvas.vue
2026-06-03 22:19:22 +08:00

313 lines
7.5 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="laser-preview-root">
<canvas
v-if="webglSupport"
id="laserPreviewWebgl"
type="webgl"
class="laser-preview-canvas"
:disable-scroll="true"
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
:width="exportCssW"
:height="exportCssH"
/>
<view v-else class="laser-preview-fallback">
<image
v-if="variantPath || fallbackPath"
class="laser-preview-fallback-img"
:src="variantPath || fallbackPath"
mode="aspectFill"
/>
<text v-else class="laser-preview-fallback-text">无预览</text>
</view>
</view>
</template>
<script setup>
import { getCurrentInstance, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { createLaserPreviewWebgl, loadTextureImage, BEAM_STYLE_HUE } from '@/utils/laser-card/laserPreviewWebgl.js'
import { resolveGratingConfig } from '@/utils/laser-card/laserGrating.js'
const props = defineProps({
/** 抠图 PNG用于闪粉 alpha 计算) */
cutoutPath: { type: String, default: '' },
/** 已生成的激光卡图WebGL 直接渲染这张) */
variantPath: { type: String, default: '' },
/** WebGL 不可用时的兜底图 */
fallbackPath: { type: String, default: '' },
preset: { type: Object, default: () => ({}) },
/** 当前选中的卡索引(用于 resolveGratingConfig 匹配导出参数) */
variantIndex: { type: Number, default: 0 },
paused: { type: Boolean, default: false },
})
const exportCssW = 360
const exportCssH = 480
const webglSupport = ref(true)
const pageProxy = getCurrentInstance()?.proxy
let _canvasNode = null
let _webglR = null
let _photoRect = null
let _rafId = null
let _engineReady = false
let _startTime = 0
function computeCover(iw, ih, cw, ch) {
const w = Number(iw) || 1
const h = Number(ih) || 1
const scale = Math.max(cw / w, ch / h)
const dw = w * scale
const dh = h * scale
return { dx: (cw - dw) / 2, dy: (ch - dh) / 2, dw, dh }
}
function scheduleFrame(cb) {
if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(cb)
return setTimeout(cb, 33)
}
function cancelFrame(id) {
if (id == null) return
try {
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(id)
else clearTimeout(id)
} catch {
/* noop */
}
}
const scheduleLoop = () => {
cancelFrame(_rafId)
if (_startTime === 0) _startTime = performance.now()
const loop = () => {
if (!_engineReady) {
_rafId = scheduleFrame(loop)
return
}
if (!props.paused && _webglR && _photoRect) {
const elapsed = performance.now() - _startTime
renderFrame(elapsed)
}
_rafId = scheduleFrame(loop)
}
_rafId = scheduleFrame(loop)
}
function stopLoop() {
cancelFrame(_rafId)
_rafId = null
}
function _resetEngine() {
_engineReady = false
_photoRect = null
_startTime = 0
if (_webglR) {
try { _webglR.destroy() } catch { /* noop */ }
_webglR = null
}
}
async function _initWebglEngine() {
if (_webglR) return true
try {
return await new Promise((resolve) => {
const q = pageProxy ? uni.createSelectorQuery().in(pageProxy) : uni.createSelectorQuery()
q.select('#laserPreviewWebgl')
.fields({ node: true, size: true })
.exec((res) => {
const row = res && res[0]
if (!row || !row.node) {
resolve(false)
return
}
_canvasNode = row.node
// 像素比:与 LaserCardStudio 保持一致的上限范围
let pr = 1
try {
const sys = uni.getSystemInfoSync()
pr = Math.min(2.5, Math.max(1, Number(sys.pixelRatio) || 1))
} catch {
pr = 1
}
const bw = Math.max(2, Math.floor(exportCssW * pr))
const bh = Math.max(2, Math.floor(exportCssH * pr))
try {
_canvasNode.width = bw
_canvasNode.height = bh
} catch {
resolve(false)
return
}
const gl =
_canvasNode.getContext('webgl', {
alpha: true,
antialias: true,
preserveDrawingBuffer: false,
stencil: false,
depth: false,
}) || _canvasNode.getContext('experimental-webgl')
if (!gl) {
webglSupport.value = false
resolve(false)
return
}
try {
_webglR = createLaserPreviewWebgl(gl, { width: exportCssW, height: exportCssH })
resolve(true)
} catch (e3) {
console.warn('[LaserPreviewCanvas] createLaserPreviewWebgl failed', e3)
webglSupport.value = false
resolve(false)
}
})
})
} catch (e) {
console.warn('[LaserPreviewCanvas] init webgl failed', e)
webglSupport.value = false
return false
}
}
async function _uploadTexturesAndRects() {
if (!_webglR || !_canvasNode) return
// cutoutPath 用于闪粉 alpha 计算(抠图 PNG 是透明底的)
const cutoutSrc = String(props.cutoutPath || '').trim()
if (!cutoutSrc) return
try {
const imgInfo = await new Promise((resolve) => {
uni.getImageInfo({ src: cutoutSrc, success: resolve, fail: () => resolve(null) })
})
if (imgInfo) {
_photoRect = computeCover(imgInfo.width, imgInfo.height, exportCssW, exportCssH)
}
} catch { /* noop */ }
try {
const photoImg = await loadTextureImage(cutoutSrc, _canvasNode)
_webglR.uploadPhoto(photoImg)
} catch {
_webglR.uploadPhoto(null)
}
_engineReady = true
if (_photoRect) {
_startTime = performance.now()
renderFrame(0)
}
}
function renderFrame(timeMs) {
if (!_webglR || !_photoRect) return
const preset = props.preset || {}
const gratingConfig = resolveGratingConfig(preset, props.variantIndex)
const beamEffectId = String(preset?.beam || 'prism')
const styleHue = typeof BEAM_STYLE_HUE[beamEffectId] === 'number' ? BEAM_STYLE_HUE[beamEffectId] : BEAM_STYLE_HUE.prism
const frostIntensity = Number(preset?.frostIntensity) || 18
const flowSpeed = Number(preset?.flowSpeed || 1)
const cw = exportCssW
const ch = exportCssH
const cornerRadiusPx = Math.min(32, cw * 0.065, ch * 0.048)
// 根据 _photoRect抠图 PNG 的 cover layout计算人物中心 UV 和边缘环形参数
const pr = _photoRect
const personEdgeX = (pr.dx + pr.dw / 2) / cw
const personEdgeY = (pr.dy + pr.dh / 2) / ch
const personEdgeInner = Math.min(pr.dw, pr.dh) / cw * 0.40
const personEdgeOuter = Math.min(pr.dw, pr.dh) / cw * 0.72
_webglR.render(timeMs, {
photoRect: _photoRect,
gratingConfig,
cornerRadiusPx,
styleHue,
animSpeed: 1,
frostIntensity,
flowSpeed,
personEdgeX,
personEdgeY,
personEdgeInner,
personEdgeOuter,
canvasW: cw,
canvasH: ch,
})
}
watch(
() => [props.cutoutPath, props.variantPath, props.preset, props.paused],
async ([cutout, variant, preset, paused]) => {
if (!cutout && !variant) return
if (!webglSupport.value) return
if (!preset) return
_resetEngine()
stopLoop()
const ok = await _initWebglEngine()
if (!ok) return
await _uploadTexturesAndRects()
if (!paused) scheduleLoop()
},
{ immediate: true, deep: true }
)
onMounted(async () => {
// 初次挂载尝试初始化;后续由 watch 处理重新渲染
if (!props.paused) scheduleLoop()
})
onBeforeUnmount(() => {
stopLoop()
try {
_webglR?.destroy?.()
} catch {
/* noop */
}
_webglR = null
_canvasNode = null
})
</script>
<style scoped>
.laser-preview-root {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.laser-preview-canvas {
border-radius: 24rpx;
}
.laser-preview-fallback {
width: 360px;
height: 480px;
border-radius: 24rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
}
.laser-preview-fallback-img {
width: 100%;
height: 100%;
}
.laser-preview-fallback-text {
color: rgba(255, 255, 255, 0.7);
font-size: 28rpx;
}
</style>