313 lines
7.5 KiB
Vue
313 lines
7.5 KiB
Vue
<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>
|
||
|