fix修复光栅卡陀螺仪问题
This commit is contained in:
parent
3a59aec48f
commit
a808c808d8
@ -56,6 +56,8 @@ const props = defineProps({
|
||||
approximatePreview: { type: Boolean, default: true },
|
||||
skipBuiltInTouch: { type: Boolean, default: false },
|
||||
tiltHintText: { type: String, default: '倾斜手机预览' },
|
||||
/** 条纹高光中线不透明度 0~1,略提可增强光栅「栅感」 */
|
||||
shimmerMidOpacity: { type: Number, default: 0.1 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['simulate'])
|
||||
@ -119,8 +121,9 @@ const cardRotateStyle = computed(() => {
|
||||
const shimmerStyle = computed(() => {
|
||||
const x = tiltVisualX.value
|
||||
const angle = 135 + (x / 120) * 60
|
||||
const a = Math.max(0, Math.min(0.35, Number(props.shimmerMidOpacity) || 0.1))
|
||||
return {
|
||||
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,0.10) 50%, transparent 70%)`,
|
||||
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,${a}) 50%, transparent 70%)`,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -73,27 +73,42 @@ export function useLenticularPreview(layersRef) {
|
||||
return Array.isArray(v) ? v : []
|
||||
}
|
||||
|
||||
function applyLayerTransformsFromRenderState(ls, renderState) {
|
||||
const next = {}
|
||||
for (const layer of ls) {
|
||||
const offset = renderState.layerOffsets.get(layer.id)
|
||||
const opacity = renderState.layerOpacities.get(layer.id)
|
||||
next[layer.id] = {
|
||||
x: offset != null ? offset.x : 0,
|
||||
y: offset != null ? offset.y : 0,
|
||||
opacity: opacity != null ? opacity : layer.opacity,
|
||||
}
|
||||
}
|
||||
layerTransforms.value = next
|
||||
stripeRender.value = {
|
||||
phaseShift: renderState.stripePhaseShift,
|
||||
shares: [...renderState.stripShares],
|
||||
pitchPx: renderState.lenticularPitchPx,
|
||||
prevLayerGhost: renderState.prevLayerGhost,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将引擎内部 displayGamma 与平滑状态对齐到当前 sensorData.gamma,并立即刷新 layerTransforms。
|
||||
* @param {{ resetLayerSmoothing?: boolean }} [options] resetLayerSmoothing:清空视差 EMA,首帧叠化更干净
|
||||
*/
|
||||
function snapSimulatedTilt(options = {}) {
|
||||
const g = clamp(sensorData.value.gamma, -1, 1)
|
||||
const ls = getLayersArray()
|
||||
const renderState = engine.snapSimulatedTilt(g, options)
|
||||
applyLayerTransformsFromRenderState(ls, renderState)
|
||||
}
|
||||
|
||||
function tick() {
|
||||
try {
|
||||
const ls = getLayersArray()
|
||||
const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
|
||||
const next = {}
|
||||
for (const layer of ls) {
|
||||
const offset = renderState.layerOffsets.get(layer.id)
|
||||
const opacity = renderState.layerOpacities.get(layer.id)
|
||||
next[layer.id] = {
|
||||
x: offset != null ? offset.x : 0,
|
||||
y: offset != null ? offset.y : 0,
|
||||
opacity: opacity != null ? opacity : layer.opacity,
|
||||
}
|
||||
}
|
||||
layerTransforms.value = next
|
||||
stripeRender.value = {
|
||||
phaseShift: renderState.stripePhaseShift,
|
||||
shares: [...renderState.stripShares],
|
||||
pitchPx: renderState.lenticularPitchPx,
|
||||
prevLayerGhost: renderState.prevLayerGhost,
|
||||
}
|
||||
applyLayerTransformsFromRenderState(ls, renderState)
|
||||
} catch (e) {
|
||||
console.error('[useLenticularPreview] tick failed', e)
|
||||
}
|
||||
@ -148,6 +163,7 @@ export function useLenticularPreview(layersRef) {
|
||||
gyro,
|
||||
simulate,
|
||||
relax,
|
||||
snapSimulatedTilt,
|
||||
engine,
|
||||
startRenderLoop,
|
||||
stopRenderLoop,
|
||||
|
||||
@ -560,7 +560,8 @@ export function useLenticularStudioTilt(opts) {
|
||||
*/
|
||||
const useSync =
|
||||
typeof mod.getGyroValueSync === 'function' && typeof mod.getGyroValue !== 'function'
|
||||
const intervalMs = useSync ? 20 : 28
|
||||
/* 略快于插件 game 传感器节奏即可;过慢会显得跟手迟滞 */
|
||||
const intervalMs = useSync ? 22 : 26
|
||||
const pollOnce = () => {
|
||||
if (myGen !== tiltGen || !gyroModule) return
|
||||
try {
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
{ "path": "pages/laser-card-studio/laser-card-studio", "style": { "navigationStyle": "custom", "backgroundColor": "#0a0a0a" } }
|
||||
App 端说明:
|
||||
- 顶栏/底栏使用 statusBarHeight、safeAreaInsets.bottom,避免仅靠 CSS 变量导致刘海屏错位。
|
||||
- 预览与导出:圆角 clip →(可选)内置镭射底图 cover → 人物 cover → HSL/幻彩/噪点等;底图可选,置于人物后方;预览用 requestAnimationFrame。
|
||||
- iOS WKWebView / Android System WebView:Canvas 合成使用 globalCompositeOperation;本页已移除 DOM 叠层预览。
|
||||
- 预览:优先 WebGL 单 Pass 全息着色(FBM + 视角色相 + 色散闪点);失败则 2D Canvas + requestAnimationFrame。导出仍为 2D 离屏 Canvas。
|
||||
- iOS WKWebView / Android System WebView:2D 分支用 globalCompositeOperation;本页已移除 DOM 叠层预览。
|
||||
- 顶栏与预览区 position: sticky,便于长屏下仍可操作顶栏。
|
||||
- 内置「强全息」默认参数(无调校 UI);保存写入相册或铸爱下单,不再弹窗询问保存调校参数。
|
||||
-->
|
||||
@ -24,19 +24,32 @@
|
||||
<view class="preview-sticky" :style="{ top: stickyHeaderTotalPx + 'px' }">
|
||||
<view class="preview-wrap">
|
||||
<block v-if="previewSrc">
|
||||
<canvas
|
||||
canvas-id="laserPreviewCanvas"
|
||||
class="preview-canvas"
|
||||
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
|
||||
:width="exportCssW"
|
||||
:height="exportCssH"
|
||||
:disable-scroll="true"
|
||||
/>
|
||||
<view class="preview-canvas-stack" :style="{ width: exportCssW + 'px', height: exportCssH + 'px' }">
|
||||
<canvas
|
||||
v-show="!webglPreviewReady"
|
||||
canvas-id="laserPreviewCanvas"
|
||||
class="preview-canvas preview-canvas--2d"
|
||||
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
|
||||
:width="exportCssW"
|
||||
:height="exportCssH"
|
||||
:disable-scroll="true"
|
||||
/>
|
||||
<canvas
|
||||
v-show="webglPreviewReady"
|
||||
id="laserPreviewWebgl"
|
||||
type="webgl"
|
||||
class="preview-canvas preview-canvas--webgl"
|
||||
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
|
||||
:width="exportCssW"
|
||||
:height="exportCssH"
|
||||
:disable-scroll="true"
|
||||
/>
|
||||
</view>
|
||||
<view class="preview-fab" @tap.stop="onPreviewTap">
|
||||
<text class="preview-fab-text">换图</text>
|
||||
</view>
|
||||
<view class="preview-replace-hint">
|
||||
<text class="preview-replace-text">全息镭射满铺预览 · 保存导出高清图</text>
|
||||
<text class="preview-replace-text">WebGL 全息预览 · 保存仍为高清 2D 导出</text>
|
||||
</view>
|
||||
</block>
|
||||
<view v-else class="preview-placeholder">
|
||||
@ -88,6 +101,11 @@ import { segmentPortraitToLocal } from '@/utils/laser-card/segmentationCloud.js'
|
||||
import { humanizeIfBareOssUploadErr } from '@/utils/laser-card/aliyunPortraitUni.js'
|
||||
import { submitCastloveAfterLaserExport } from '@/utils/castloveAfterLaserMint.js'
|
||||
import { CASTLOVE_LASER_ENTRY_KEY } from '@/utils/castloveMintForm.js'
|
||||
import {
|
||||
createLaserPreviewWebgl,
|
||||
loadTextureImage,
|
||||
BEAM_STYLE_HUE
|
||||
} from '@/utils/laser-card/laserPreviewWebgl.js'
|
||||
|
||||
const PARAM_PRESET_STORAGE_KEY = 'laser_card_param_presets_v1'
|
||||
|
||||
@ -237,7 +255,14 @@ export default {
|
||||
/** 常用光束角度快捷(度) */
|
||||
angleQuickDegrees: [0, 15, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330],
|
||||
/** 铸爱单图入口:create 写入,保存后走下单 */
|
||||
castloveEntry: null
|
||||
castloveEntry: null,
|
||||
/** WebGL 预览:成功前仍走 2D 回退 */
|
||||
webglPreviewReady: false,
|
||||
_webglR: null,
|
||||
_webglCanvasNode: null,
|
||||
_webglBooting: false,
|
||||
/** 递增以作废进行中的 WebGL 异步启动 */
|
||||
laserWebglSession: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -763,6 +788,7 @@ export default {
|
||||
previewSrc(src) {
|
||||
this._previewLayout = null
|
||||
this._stopPreviewAnimation()
|
||||
this._destroyWebglPreview()
|
||||
if (src) {
|
||||
this.$nextTick(() => {
|
||||
this._refreshPreviewLayoutFromSrc()
|
||||
@ -811,6 +837,7 @@ export default {
|
||||
},
|
||||
onUnload() {
|
||||
this._stopPreviewAnimation()
|
||||
this._destroyWebglPreview()
|
||||
},
|
||||
methods: {
|
||||
_computeCoverLayout(iw, ih, cw, ch) {
|
||||
@ -896,6 +923,7 @@ export default {
|
||||
src: p,
|
||||
success: (img) => {
|
||||
this._backdropLayout = this._computeCoverLayout(img.width, img.height, cw, ch)
|
||||
this._reloadWebglBackdropTextureOnly()
|
||||
},
|
||||
fail: (err) => {
|
||||
console.warn('[LaserCardStudio] backdrop getImageInfo', err)
|
||||
@ -999,6 +1027,190 @@ export default {
|
||||
return uni.createCanvasContext(canvasId, this)
|
||||
// #endif
|
||||
},
|
||||
_destroyWebglPreview() {
|
||||
this.laserWebglSession++
|
||||
this.webglPreviewReady = false
|
||||
this._webglBooting = false
|
||||
if (this._webglR) {
|
||||
try {
|
||||
this._webglR.destroy()
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
this._webglR = null
|
||||
}
|
||||
this._webglCanvasNode = null
|
||||
},
|
||||
/**
|
||||
* 查询 type=webgl 画布、按 dpr 设物理像素、编译着色器。
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
_initWebglPreviewEngine() {
|
||||
return new Promise((resolve) => {
|
||||
if (this._webglR) {
|
||||
try {
|
||||
this._webglR.destroy()
|
||||
} catch (e) {
|
||||
/* noop */
|
||||
}
|
||||
this._webglR = null
|
||||
}
|
||||
try {
|
||||
const q = uni.createSelectorQuery().in(this)
|
||||
q.select('#laserPreviewWebgl')
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res) => {
|
||||
const row = res && res[0]
|
||||
if (!row || !row.node) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
const canvas = row.node
|
||||
this._webglCanvasNode = canvas
|
||||
let pr = 1
|
||||
try {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
pr = Math.min(2.5, Math.max(1, Number(sys.pixelRatio) || 1))
|
||||
} catch (e1) {
|
||||
pr = 1
|
||||
}
|
||||
const bw = Math.max(2, Math.floor(this.exportCssW * pr))
|
||||
const bh = Math.max(2, Math.floor(this.exportCssH * pr))
|
||||
try {
|
||||
canvas.width = bw
|
||||
canvas.height = bh
|
||||
} catch (e2) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
const gl =
|
||||
canvas.getContext('webgl', {
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: false,
|
||||
stencil: false,
|
||||
depth: false
|
||||
}) || canvas.getContext('experimental-webgl')
|
||||
if (!gl) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
this._webglR = createLaserPreviewWebgl(gl, {
|
||||
width: this.exportCssW,
|
||||
height: this.exportCssH
|
||||
})
|
||||
resolve(true)
|
||||
} catch (e3) {
|
||||
console.warn('[LaserCardStudio] WebGL program', e3)
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
},
|
||||
async _loadWebglTextures() {
|
||||
if (!this._webglR || !this.previewSrc || !this._webglCanvasNode) {
|
||||
return
|
||||
}
|
||||
const photoImg = await loadTextureImage(this.previewSrc, this._webglCanvasNode)
|
||||
this._webglR.uploadPhoto(photoImg)
|
||||
const bp = this._resolveLaserBackdropPath()
|
||||
if (bp) {
|
||||
try {
|
||||
const bi = await loadTextureImage(bp, this._webglCanvasNode)
|
||||
this._webglR.uploadBackdrop(bi)
|
||||
} catch (e) {
|
||||
this._webglR.uploadBackdrop(null)
|
||||
}
|
||||
} else {
|
||||
this._webglR.uploadBackdrop(null)
|
||||
}
|
||||
},
|
||||
async _reloadWebglBackdropTextureOnly() {
|
||||
if (!this._webglR || !this._webglCanvasNode) {
|
||||
return
|
||||
}
|
||||
const sess = this.laserWebglSession
|
||||
const bp = this._resolveLaserBackdropPath()
|
||||
if (!bp) {
|
||||
this._webglR.uploadBackdrop(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const bi = await loadTextureImage(bp, this._webglCanvasNode)
|
||||
if (sess !== this.laserWebglSession) {
|
||||
return
|
||||
}
|
||||
this._webglR.uploadBackdrop(bi)
|
||||
} catch (e) {
|
||||
if (sess !== this.laserWebglSession) {
|
||||
return
|
||||
}
|
||||
this._webglR.uploadBackdrop(null)
|
||||
}
|
||||
},
|
||||
async _bootWebglPreview() {
|
||||
if (this._webglBooting || !this.previewSrc || !this._previewLayout) {
|
||||
return
|
||||
}
|
||||
this._webglBooting = true
|
||||
const sess = this.laserWebglSession
|
||||
try {
|
||||
const ok = await this._initWebglPreviewEngine()
|
||||
if (sess !== this.laserWebglSession) {
|
||||
return
|
||||
}
|
||||
if (!ok) {
|
||||
this.webglPreviewReady = false
|
||||
return
|
||||
}
|
||||
await this._loadWebglTextures()
|
||||
if (sess !== this.laserWebglSession) {
|
||||
return
|
||||
}
|
||||
if (!this._webglR || !this._webglR.hasPhoto()) {
|
||||
this.webglPreviewReady = false
|
||||
return
|
||||
}
|
||||
this.webglPreviewReady = true
|
||||
} catch (e) {
|
||||
console.warn('[LaserCardStudio] WebGL boot', e)
|
||||
this.webglPreviewReady = false
|
||||
} finally {
|
||||
this._webglBooting = false
|
||||
}
|
||||
},
|
||||
_renderWebglPreviewFrame(t) {
|
||||
if (!this.webglPreviewReady || !this._webglR || !this._previewLayout) {
|
||||
return
|
||||
}
|
||||
const timeMs = typeof t === 'number' ? t : Date.now()
|
||||
const vivid = Math.max(0.72, Math.min(1.35, (Number(this.previewVivid) || 100) / 100))
|
||||
const strength = Math.max(0.42, Math.min(1.12, (Number(this.laserStrength) || 65) / 100))
|
||||
const animSpeed = Math.max(0.35, Math.min(1.8, (Number(this.previewAnimSpeed) || 100) / 100))
|
||||
const styleHue =
|
||||
typeof BEAM_STYLE_HUE[this.beamEffectId] === 'number'
|
||||
? BEAM_STYLE_HUE[this.beamEffectId]
|
||||
: BEAM_STYLE_HUE.prism
|
||||
const cw = this.exportCssW
|
||||
const ch = this.exportCssH
|
||||
const hasBd = !!(this._backdropLayout && this._resolveLaserBackdropPath())
|
||||
this._webglR.render(timeMs, {
|
||||
photoRect: this._previewLayout,
|
||||
backRect: this._backdropLayout,
|
||||
hasBackdrop: hasBd,
|
||||
cornerRadiusPx: Math.min(32, cw * 0.065, ch * 0.048),
|
||||
vivid,
|
||||
strength,
|
||||
styleHue,
|
||||
animSpeed,
|
||||
canvasW: cw,
|
||||
canvasH: ch
|
||||
})
|
||||
},
|
||||
_schedulePreviewFrame(cb) {
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
return requestAnimationFrame(cb)
|
||||
@ -1054,6 +1266,7 @@ export default {
|
||||
setTimeout(() => {
|
||||
this._refreshBackdropLayout()
|
||||
this._startPreviewAnimation()
|
||||
this._bootWebglPreview()
|
||||
}, 48)
|
||||
})
|
||||
},
|
||||
@ -1073,7 +1286,11 @@ export default {
|
||||
this._previewRafId = null
|
||||
return
|
||||
}
|
||||
this._renderLaserPreviewFrame()
|
||||
if (this.webglPreviewReady && this._webglR) {
|
||||
this._renderWebglPreviewFrame(Date.now())
|
||||
} else {
|
||||
this._renderLaserPreviewFrame()
|
||||
}
|
||||
this._previewRafId = this._schedulePreviewFrame(loop)
|
||||
}
|
||||
this._previewRafId = this._schedulePreviewFrame(loop)
|
||||
@ -2458,6 +2675,24 @@ export default {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-canvas-stack {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-canvas--2d,
|
||||
.preview-canvas--webgl {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview-canvas--webgl {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.preview-canvas {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
:gyro-source="gyroSourceLabel"
|
||||
:skip-built-in-touch="true"
|
||||
:tilt-hint-text="''"
|
||||
:shimmer-mid-opacity="0.16"
|
||||
@simulate="simulate"
|
||||
/>
|
||||
</view>
|
||||
@ -84,14 +85,21 @@ import {
|
||||
const layers = ref([])
|
||||
const gyroSourceLabel = ref('simulation')
|
||||
|
||||
const { physics, layerTransforms, simulate } = useLenticularPreview(layers)
|
||||
const { physics, layerTransforms, simulate, relax, snapSimulatedTilt } = useLenticularPreview(layers)
|
||||
|
||||
/** 相对进入时基准的倾角标量(度),每满 15° 一档;左右倾斜等价 */
|
||||
const STUDIO_DISCRETE_STEP_DEG = 15
|
||||
/** 档位施密特迟滞(度),减轻在档位边界来回跳 */
|
||||
const DISCRETE_STEP_HYST_DEG = 4
|
||||
/** 相对进入时基准的倾角标量(度),每满一档换一层;越大需掰得越狠才换图,体感更慢 */
|
||||
const STUDIO_DISCRETE_STEP_DEG = 13
|
||||
/** 档位施密特迟滞(度),减轻在档位边界来回跳;换向经过水平附近时略加大更稳 */
|
||||
const DISCRETE_STEP_HYST_DEG = 5
|
||||
/** 进入离散逻辑前对倾角幅度做 EMA;上升快、下降慢,减轻换向时「先掉档再升档」的抖动 */
|
||||
const STUDIO_TILT_MAG_EMA = 0.17
|
||||
const STUDIO_TILT_MAG_EMA_DECAY_RATIO = 0.42
|
||||
/** 进入页后延迟开启倾斜监听(ms),避免首帧/基线采集/大图解码时画面跟着抖 */
|
||||
const STUDIO_TILT_START_DELAY_MS = 560
|
||||
|
||||
const discreteStableStep = ref(0)
|
||||
/** 与离散档位同步平滑的倾角幅度(度),换图时缓变避免观感断裂 */
|
||||
let studioTiltMagEma = 0
|
||||
|
||||
/**
|
||||
* 将相对基准的倾角标量(度,非负)映射为 LenticularEngine 的 gamma。
|
||||
@ -100,10 +108,14 @@ const discreteStableStep = ref(0)
|
||||
function simulateFromSignedDegrees(tiltMagDeg) {
|
||||
const ls = layers.value || []
|
||||
const n = Math.max(1, ls.length)
|
||||
const absDeg = Math.abs(Number(tiltMagDeg) || 0)
|
||||
const raw = Math.abs(Number(tiltMagDeg) || 0)
|
||||
const emaAlpha = raw >= studioTiltMagEma ? STUDIO_TILT_MAG_EMA : STUDIO_TILT_MAG_EMA * STUDIO_TILT_MAG_EMA_DECAY_RATIO
|
||||
studioTiltMagEma += (raw - studioTiltMagEma) * emaAlpha
|
||||
const absDeg = studioTiltMagEma
|
||||
const STEP = STUDIO_DISCRETE_STEP_DEG
|
||||
const MARGIN = DISCRETE_STEP_HYST_DEG
|
||||
let s = discreteStableStep.value
|
||||
const stepBefore = discreteStableStep.value
|
||||
let s = stepBefore
|
||||
while (absDeg >= (s + 1) * STEP + MARGIN) s++
|
||||
while (s > 0 && absDeg <= s * STEP - MARGIN) s--
|
||||
discreteStableStep.value = s
|
||||
@ -114,6 +126,10 @@ function simulateFromSignedDegrees(tiltMagDeg) {
|
||||
const gPick = Math.max(-1, Math.min(1, 2 * u - 1))
|
||||
const gamma = Math.max(-1, Math.min(1, gPick / mul))
|
||||
simulate(gamma, 0)
|
||||
/* 档位切换瞬间把引擎 displayGamma 对齐到目标,避免多帧渐近 + 宽叠化带叠成「糊、抖」 */
|
||||
if (s !== stepBefore) {
|
||||
snapSimulatedTilt({ resetLayerSmoothing: false })
|
||||
}
|
||||
}
|
||||
|
||||
const { start: startTilt, stop: stopTilt, recalibrate: recalibrateTilt } = useLenticularStudioTilt({
|
||||
@ -123,9 +139,20 @@ const { start: startTilt, stop: stopTilt, recalibrate: recalibrateTilt } = useLe
|
||||
useStudioAccelDirect: true,
|
||||
onTiltDriverFallback: () => {
|
||||
discreteStableStep.value = 0
|
||||
studioTiltMagEma = 0
|
||||
},
|
||||
})
|
||||
|
||||
/** 首屏与换素材后:档位与 EMA 归零,并写入与 0° 档位一致的 gamma(传感器未开时保持静止) */
|
||||
function lockStudioPreviewStill() {
|
||||
discreteStableStep.value = 0
|
||||
studioTiltMagEma = 0
|
||||
simulateFromSignedDegrees(0)
|
||||
snapSimulatedTilt({ resetLayerSmoothing: true })
|
||||
}
|
||||
|
||||
let entryTiltTimer = null
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
@ -143,6 +170,9 @@ function onMore() {
|
||||
try {
|
||||
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
|
||||
discreteStableStep.value = 0
|
||||
studioTiltMagEma = 0
|
||||
nextTick(() => relax(0.86))
|
||||
uni.showToast({ title: '已恢复', icon: 'none' })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@ -175,6 +205,7 @@ function pickImage(layerId) {
|
||||
const copy = [...layers.value]
|
||||
copy[idx] = next
|
||||
layers.value = copy
|
||||
nextTick(() => relax(0.86))
|
||||
},
|
||||
fail: (err) => {
|
||||
const msg = (err && err.errMsg) || ''
|
||||
@ -193,11 +224,17 @@ function resetLayer(layerId) {
|
||||
const copy = [...layers.value]
|
||||
copy[idx] = { ...defLayer }
|
||||
layers.value = copy
|
||||
nextTick(() => relax(0.86))
|
||||
}
|
||||
|
||||
function onRecalibrate() {
|
||||
discreteStableStep.value = 0
|
||||
studioTiltMagEma = 0
|
||||
recalibrateTilt()
|
||||
nextTick(() => {
|
||||
simulateFromSignedDegrees(0)
|
||||
snapSimulatedTilt({ resetLayerSmoothing: true })
|
||||
})
|
||||
uni.showToast({ title: '已重置水平基准', icon: 'none' })
|
||||
}
|
||||
|
||||
@ -211,6 +248,18 @@ onMounted(() => {
|
||||
}
|
||||
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
|
||||
/* 光栅感:略抬残影/ghost + 叠化带宽;开启轻量视差,换图时另一张轮廓更易分辨 */
|
||||
Object.assign(physics, {
|
||||
angleStability: 40,
|
||||
transitionSmoothness: 28,
|
||||
tiltSensitivity: 96,
|
||||
sensorDeadzoneStrength: 0,
|
||||
parallaxDepth: 18,
|
||||
lenticularAnchorFloor: 0.1,
|
||||
lenticularNonDominantResidualMin: 0.092,
|
||||
lenticularPrevLayerGhostMin: 0.098,
|
||||
lenticularBlendBaseScale: 0.95,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[lenticular-studio] load payload', e)
|
||||
uni.showToast({ title: '数据无效', icon: 'none' })
|
||||
@ -218,16 +267,23 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
/* 离散换图:略降过渡混合,档位切换更利落 */
|
||||
physics.angleStability = 6
|
||||
physics.transitionSmoothness = 12
|
||||
physics.tiltSensitivity = 96
|
||||
physics.sensorDeadzoneStrength = 0
|
||||
startTilt()
|
||||
lockStudioPreviewStill()
|
||||
if (entryTiltTimer != null) {
|
||||
clearTimeout(entryTiltTimer)
|
||||
entryTiltTimer = null
|
||||
}
|
||||
entryTiltTimer = setTimeout(() => {
|
||||
entryTiltTimer = null
|
||||
startTilt()
|
||||
}, STUDIO_TILT_START_DELAY_MS)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (entryTiltTimer != null) {
|
||||
clearTimeout(entryTiltTimer)
|
||||
entryTiltTimer = null
|
||||
}
|
||||
stopTilt()
|
||||
})
|
||||
</script>
|
||||
@ -334,6 +390,8 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
contain: layout paint;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.canvas-bg {
|
||||
|
||||
396
frontend/utils/laser-card/laserPreviewWebgl.js
Normal file
396
frontend/utils/laser-card/laserPreviewWebgl.js
Normal file
@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 镭射卡预览专用 WebGL1 渲染器(全息光栅风格单 Pass)。
|
||||
* 预览与导出分离:导出仍走 2D Canvas;本模块仅用于页面内实时预览。
|
||||
*
|
||||
* 依赖:WebGL1 + OES_texture_float_linear 非必须;仅用 RGBA8 纹理。
|
||||
*/
|
||||
|
||||
const VERT_SRC = `
|
||||
attribute vec2 a_uv;
|
||||
varying vec2 v_uv;
|
||||
void main() {
|
||||
v_uv = a_uv;
|
||||
gl_Position = vec4(a_uv * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRAG_SRC = `
|
||||
precision highp float;
|
||||
varying vec2 v_uv;
|
||||
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time;
|
||||
uniform vec4 u_photoRect;
|
||||
uniform vec4 u_backRect;
|
||||
uniform float u_hasBackdrop;
|
||||
uniform float u_corner;
|
||||
uniform vec3 u_view;
|
||||
uniform float u_vivid;
|
||||
uniform float u_strength;
|
||||
uniform float u_styleHue;
|
||||
|
||||
uniform sampler2D u_photo;
|
||||
uniform sampler2D u_back;
|
||||
|
||||
const vec3 LUMA = vec3(0.299, 0.587, 0.114);
|
||||
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
|
||||
}
|
||||
|
||||
float n2(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
float a = hash(i);
|
||||
float b = hash(i + vec2(1.0, 0.0));
|
||||
float c = hash(i + vec2(0.0, 1.0));
|
||||
float d = hash(i + vec2(1.0, 1.0));
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
|
||||
}
|
||||
|
||||
float fbm(vec2 p) {
|
||||
float v = 0.0;
|
||||
float a = 0.52;
|
||||
mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
|
||||
for (int i = 0; i < 6; i++) {
|
||||
v += a * n2(p);
|
||||
p = m * p;
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
float roundedBox(vec2 p, vec2 b, float r) {
|
||||
vec2 q = abs(p) - b + r;
|
||||
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
|
||||
}
|
||||
|
||||
vec3 hsl2rgb(float h, float s, float l) {
|
||||
float c = (1.0 - abs(2.0 * l - 1.0)) * s;
|
||||
float x = c * (1.0 - abs(mod(h / 60.0, 2.0) - 1.0));
|
||||
float m = l - 0.5 * c;
|
||||
vec3 rgb;
|
||||
if (h < 60.0) rgb = vec3(c, x, 0.0);
|
||||
else if (h < 120.0) rgb = vec3(x, c, 0.0);
|
||||
else if (h < 180.0) rgb = vec3(0.0, c, x);
|
||||
else if (h < 240.0) rgb = vec3(0.0, x, c);
|
||||
else if (h < 300.0) rgb = vec3(x, 0.0, c);
|
||||
else rgb = vec3(c, 0.0, x);
|
||||
return rgb + m;
|
||||
}
|
||||
|
||||
vec4 samplePhoto(vec2 uv) {
|
||||
vec2 pRaw = (uv - u_photoRect.xy) / u_photoRect.zw;
|
||||
vec2 p = clamp(pRaw, 0.0, 1.0);
|
||||
return texture2D(u_photo, p);
|
||||
}
|
||||
|
||||
float photoInside(vec2 uv) {
|
||||
vec2 pRaw = (uv - u_photoRect.xy) / u_photoRect.zw;
|
||||
return step(0.0, pRaw.x) * step(pRaw.x, 1.0) * step(0.0, pRaw.y) * step(pRaw.y, 1.0);
|
||||
}
|
||||
|
||||
vec3 sampleBackdrop(vec2 uv) {
|
||||
vec2 p = (uv - u_backRect.xy) / u_backRect.zw;
|
||||
if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0) return vec3(0.04, 0.03, 0.06);
|
||||
return texture2D(u_back, p).rgb;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_uv;
|
||||
vec2 pN = (uv - 0.5) * vec2(u_resolution.x / u_resolution.y, 1.0);
|
||||
float rb = roundedBox(pN, vec2(0.5 * u_resolution.x / u_resolution.y, 0.5), u_corner);
|
||||
if (rb > 0.002) discard;
|
||||
|
||||
vec3 baseBg = vec3(0.06, 0.045, 0.09);
|
||||
if (u_hasBackdrop > 0.5) {
|
||||
baseBg = sampleBackdrop(uv);
|
||||
}
|
||||
|
||||
vec4 pc = samplePhoto(uv);
|
||||
float photoMask = photoInside(uv);
|
||||
vec3 photoRgb = pc.rgb;
|
||||
|
||||
float eps = 1.2 / u_resolution.x;
|
||||
vec4 px1 = samplePhoto(uv + vec2(eps, 0.0));
|
||||
vec4 px2 = samplePhoto(uv + vec2(0.0, eps));
|
||||
float l0 = dot(photoRgb, LUMA);
|
||||
float l1 = dot(px1.rgb, LUMA);
|
||||
float l2 = dot(px2.rgb, LUMA);
|
||||
float edge = clamp(abs(l1 - l0) + abs(l2 - l0), 0.0, 1.0) * 3.5;
|
||||
|
||||
vec2 pF = uv * vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0) * 3.4;
|
||||
float f = fbm(pF + u_view.xy * 0.15);
|
||||
float f2 = fbm(pF * 1.7 + vec2(13.1, 9.7) + u_view.z * 0.08);
|
||||
vec2 g = vec2(fbm(pF + vec2(1.7, 0.0)) - fbm(pF - vec2(1.7, 0.0)), fbm(pF + vec2(0.0, 1.7)) - fbm(pF - vec2(0.0, 1.7)));
|
||||
float ang = atan(g.y, g.x) + u_view.x * 1.15 + u_view.y * 0.9 + f * 6.28318 * 0.35;
|
||||
float film = sin(dot(uv * u_resolution.xy * 0.02, vec2(1.0, 1.73)) * 3.14159 + u_time * 0.0012 + u_view.z);
|
||||
float hue = fract(ang / 6.28318 + f2 * 0.22 + u_styleHue / 360.0 + u_time * 0.00008 + film * 0.08);
|
||||
float sat = 0.72 + u_vivid * 0.28;
|
||||
float lit = 0.48 + f * 0.22;
|
||||
vec3 holo = hsl2rgb(hue * 360.0, sat, lit);
|
||||
|
||||
float carrier = smoothstep(0.15, 0.95, pow(f, 0.85)) * (0.35 + edge * 0.85);
|
||||
carrier = clamp(carrier * u_strength * (0.55 + u_vivid * 0.45), 0.0, 1.35);
|
||||
|
||||
vec3 holoRgb = holo * (0.55 + 0.45 * sin(u_time * 0.001 + f * 10.0));
|
||||
float sp = step(0.988, hash(floor(uv * u_resolution * 1.2) + floor(u_time * 0.02)));
|
||||
vec3 sparkle = vec3(1.0) * sp * 0.55;
|
||||
|
||||
vec3 base = mix(baseBg, photoRgb, photoMask);
|
||||
vec3 ir = holoRgb * carrier;
|
||||
vec3 screenBlend = 1.0 - (1.0 - base) * (1.0 - clamp(ir * 1.1, 0.0, 1.0));
|
||||
vec3 col = mix(base, screenBlend, clamp(carrier * 0.92, 0.0, 1.0));
|
||||
col += sparkle * carrier;
|
||||
|
||||
float ca = 0.0018 * (0.5 + u_vivid);
|
||||
vec2 o = vec2(ca * sin(u_view.z), ca * cos(u_view.z * 0.7));
|
||||
vec3 cr = hsl2rgb(fract(hue + 0.02) * 360.0, sat * 0.9, lit * 1.05);
|
||||
vec3 cb = hsl2rgb(fract(hue - 0.03) * 360.0, sat * 0.95, lit * 0.95);
|
||||
col.r = mix(col.r, cr.r, 0.12 * carrier);
|
||||
col.b = mix(col.b, cb.b, 0.12 * carrier);
|
||||
|
||||
float vign = 1.0 - pow(length(pN) / 0.72, 2.2) * 0.12;
|
||||
col *= vign;
|
||||
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
function compileShader(gl, type, src) {
|
||||
const sh = gl.createShader(type)
|
||||
gl.shaderSource(sh, src)
|
||||
gl.compileShader(sh)
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
const err = gl.getShaderInfoLog(sh)
|
||||
gl.deleteShader(sh)
|
||||
throw new Error(err || 'shader compile')
|
||||
}
|
||||
return sh
|
||||
}
|
||||
|
||||
function createProgram(gl, vs, fs) {
|
||||
const prog = gl.createProgram()
|
||||
gl.attachShader(prog, compileShader(gl, gl.VERTEX_SHADER, vs))
|
||||
gl.attachShader(prog, compileShader(gl, gl.FRAGMENT_SHADER, fs))
|
||||
gl.linkProgram(prog)
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||
const err = gl.getProgramInfoLog(prog)
|
||||
gl.deleteProgram(prog)
|
||||
throw new Error(err || 'program link')
|
||||
}
|
||||
return prog
|
||||
}
|
||||
|
||||
function orthoTexImage(gl, tex, source) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.bindTexture(gl.TEXTURE_2D, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {WebGLRenderingContext} gl
|
||||
* @param {{ width: number, height: number, canvas?: any }} opts
|
||||
*/
|
||||
export function createLaserPreviewWebgl(gl, opts) {
|
||||
const w = Math.max(2, Math.floor(opts.width))
|
||||
const h = Math.max(2, Math.floor(opts.height))
|
||||
|
||||
const program = createProgram(gl, VERT_SRC, FRAG_SRC)
|
||||
const loc = {
|
||||
a_uv: gl.getAttribLocation(program, 'a_uv'),
|
||||
u_resolution: gl.getUniformLocation(program, 'u_resolution'),
|
||||
u_time: gl.getUniformLocation(program, 'u_time'),
|
||||
u_photoRect: gl.getUniformLocation(program, 'u_photoRect'),
|
||||
u_backRect: gl.getUniformLocation(program, 'u_backRect'),
|
||||
u_hasBackdrop: gl.getUniformLocation(program, 'u_hasBackdrop'),
|
||||
u_corner: gl.getUniformLocation(program, 'u_corner'),
|
||||
u_view: gl.getUniformLocation(program, 'u_view'),
|
||||
u_vivid: gl.getUniformLocation(program, 'u_vivid'),
|
||||
u_strength: gl.getUniformLocation(program, 'u_strength'),
|
||||
u_styleHue: gl.getUniformLocation(program, 'u_styleHue'),
|
||||
u_photo: gl.getUniformLocation(program, 'u_photo'),
|
||||
u_back: gl.getUniformLocation(program, 'u_back')
|
||||
}
|
||||
|
||||
const buf = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
const quad = new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1])
|
||||
gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW)
|
||||
|
||||
const texPhoto = gl.createTexture()
|
||||
const texBack = gl.createTexture()
|
||||
let photoReady = false
|
||||
let backReady = false
|
||||
|
||||
function bindUnit(unit, tex, loc) {
|
||||
gl.activeTexture(gl.TEXTURE0 + unit)
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
gl.uniform1i(loc, unit)
|
||||
}
|
||||
|
||||
return {
|
||||
gl,
|
||||
w,
|
||||
h,
|
||||
program,
|
||||
/** 释放 GPU 资源 */
|
||||
destroy() {
|
||||
gl.deleteProgram(program)
|
||||
gl.deleteBuffer(buf)
|
||||
gl.deleteTexture(texPhoto)
|
||||
gl.deleteTexture(texBack)
|
||||
},
|
||||
/** @param {HTMLImageElement|ImageBitmap|HTMLCanvasElement} img */
|
||||
uploadPhoto(img) {
|
||||
if (!img || !img.width) return
|
||||
orthoTexImage(gl, texPhoto, img)
|
||||
photoReady = true
|
||||
},
|
||||
/** @param {HTMLImageElement|ImageBitmap|HTMLCanvasElement|null} img */
|
||||
uploadBackdrop(img) {
|
||||
if (!img || !img.width) {
|
||||
backReady = false
|
||||
return
|
||||
}
|
||||
orthoTexImage(gl, texBack, img)
|
||||
backReady = true
|
||||
},
|
||||
hasPhoto() {
|
||||
return photoReady
|
||||
},
|
||||
hasBackdrop() {
|
||||
return backReady
|
||||
},
|
||||
/**
|
||||
* @param {number} timeMs
|
||||
* @param {object} u
|
||||
* @param {{ dx: number, dy: number, dw: number, dh: number }} u.photoRect 像素 cover 矩形
|
||||
* @param {object} [u.backRect]
|
||||
* @param {boolean} [u.hasBackdrop]
|
||||
* @param {number} [u.cornerRadiusPx]
|
||||
* @param {number} [u.vivid] 0.75~1.35
|
||||
* @param {number} [u.strength] 0.35~1.2
|
||||
* @param {number} [u.styleHue] beam 风格色相偏置 0~360
|
||||
*/
|
||||
render(timeMs, u) {
|
||||
if (!photoReady) return
|
||||
const pr = u.photoRect
|
||||
const cw = u.canvasW || w
|
||||
const ch = u.canvasH || h
|
||||
const nx = pr.dx / cw
|
||||
const ny = pr.dy / ch
|
||||
const nw = pr.dw / cw
|
||||
const nh = pr.dh / ch
|
||||
|
||||
let bx = 0
|
||||
let nyb = 0
|
||||
let nbw = 1
|
||||
let nbh = 1
|
||||
let hasB = !!(u.hasBackdrop && backReady && u.backRect)
|
||||
if (hasB) {
|
||||
const br = u.backRect
|
||||
bx = br.dx / cw
|
||||
nyb = br.dy / ch
|
||||
nbw = br.dw / cw
|
||||
nbh = br.dh / ch
|
||||
}
|
||||
|
||||
const rPx = typeof u.cornerRadiusPx === 'number' ? u.cornerRadiusPx : Math.min(32, cw * 0.065, ch * 0.048)
|
||||
const cornerUv = rPx / Math.min(cw, ch)
|
||||
|
||||
const t = (timeMs || 0) * 0.001
|
||||
const speed = typeof u.animSpeed === 'number' ? u.animSpeed : 1
|
||||
const vx = Math.sin(t * 0.42 * speed) * 0.7 + Math.sin(t * 0.17 * speed) * 0.35
|
||||
const vy = Math.cos(t * 0.31 * speed) * 0.55 + Math.cos(t * 0.21 * speed) * 0.28
|
||||
const vz = t * 0.55 * speed
|
||||
|
||||
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)
|
||||
gl.disable(gl.DEPTH_TEST)
|
||||
gl.disable(gl.STENCIL_TEST)
|
||||
gl.clearColor(0.04, 0.03, 0.06, 1)
|
||||
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||
|
||||
gl.useProgram(program)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
gl.enableVertexAttribArray(loc.a_uv)
|
||||
gl.vertexAttribPointer(loc.a_uv, 2, gl.FLOAT, false, 0, 0)
|
||||
|
||||
gl.uniform2f(loc.u_resolution, cw, ch)
|
||||
gl.uniform1f(loc.u_time, timeMs || 0)
|
||||
gl.uniform4f(loc.u_photoRect, nx, ny, nw, nh)
|
||||
gl.uniform4f(loc.u_backRect, bx, nyb, nbw, nbh)
|
||||
gl.uniform1f(loc.u_hasBackdrop, hasB ? 1 : 0)
|
||||
gl.uniform1f(loc.u_corner, cornerUv)
|
||||
gl.uniform3f(loc.u_view, vx, vy, vz)
|
||||
gl.uniform1f(loc.u_vivid, typeof u.vivid === 'number' ? u.vivid : 1)
|
||||
gl.uniform1f(loc.u_strength, typeof u.strength === 'number' ? u.strength : 0.82)
|
||||
gl.uniform1f(loc.u_styleHue, typeof u.styleHue === 'number' ? u.styleHue : 0)
|
||||
|
||||
bindUnit(0, texPhoto, loc.u_photo)
|
||||
bindUnit(1, texBack, loc.u_back)
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6)
|
||||
gl.bindTexture(gl.TEXTURE_2D, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地路径或 URL 加载图片;App/H5 用 Image,小程序等用 canvas.createImage。
|
||||
* @param {string} src
|
||||
* @param {any} [canvasForCreateImage] type=webgl 的 canvas node
|
||||
*/
|
||||
export function loadTextureImage(src, canvasForCreateImage) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!src) {
|
||||
reject(new Error('empty src'))
|
||||
return
|
||||
}
|
||||
const c = canvasForCreateImage
|
||||
if (c && typeof c.createImage === 'function') {
|
||||
try {
|
||||
const img = c.createImage()
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('createImage load'))
|
||||
img.src = src
|
||||
return
|
||||
} catch (e2) {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
if (typeof Image !== 'undefined') {
|
||||
try {
|
||||
const img = new Image()
|
||||
try {
|
||||
img.crossOrigin = 'anonymous'
|
||||
} catch (e0) {
|
||||
/* noop */
|
||||
}
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('image load'))
|
||||
img.src = src
|
||||
return
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
return
|
||||
}
|
||||
}
|
||||
reject(new Error('no image loader'))
|
||||
})
|
||||
}
|
||||
|
||||
/** 各 beamEffect 对应色相偏置(度),与 2D 视觉大致同系 */
|
||||
export const BEAM_STYLE_HUE = {
|
||||
white: 0,
|
||||
prism: 18,
|
||||
aurora: 42,
|
||||
sunset: 6,
|
||||
neon: 72
|
||||
}
|
||||
@ -22,6 +22,18 @@ export const DEFAULT_PHYSICS = {
|
||||
lenticularPitchPx: 16,
|
||||
/** 1 = 默认;0 = 关闭 displayGamma 近零软衰减(硬件倾斜起步更跟手) */
|
||||
sensorDeadzoneStrength: 1,
|
||||
/**
|
||||
* 以下为可选叠化微调(undefined 时用引擎内置默认)。
|
||||
* 光栅工作室离散档位预览可压低「另一张图」残影。
|
||||
*/
|
||||
/** 首层(常为底图)最低可见度 0~1,默认约 0.18 */
|
||||
lenticularAnchorFloor: undefined,
|
||||
/** 非主导层最低残影 0~1,默认约 0.055 */
|
||||
lenticularNonDominantResidualMin: undefined,
|
||||
/** 主导层为上层时,下层最低 ghost 0~1,默认约 0.065 */
|
||||
lenticularPrevLayerGhostMin: undefined,
|
||||
/** 叠化带宽系数,越小越「干净」、越大越柔和,默认 1 */
|
||||
lenticularBlendBaseScale: undefined,
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
@ -48,7 +60,8 @@ function softAttenuateNearZero(g, db) {
|
||||
export class LenticularEngine {
|
||||
constructor(physics) {
|
||||
this.layers = []
|
||||
this.physics = { ...physics }
|
||||
/** 与 useLenticularPreview 传入的 reactive 对象保持同一引用,便于页面侧调参即时生效 */
|
||||
this.physics = physics
|
||||
this.smoothedSensor = { gamma: 0, beta: 0, timestamp: 0 }
|
||||
this.displayGamma = 0
|
||||
this.layerOffsetXSmoothed = new Map()
|
||||
@ -64,12 +77,18 @@ export class LenticularEngine {
|
||||
}
|
||||
|
||||
setLayers(layers) {
|
||||
const prevOffsetX = new Map(this.layerOffsetXSmoothed)
|
||||
const prevParallaxBlendT = this.parallaxBlendTSmoothed
|
||||
this.layers = [...layers]
|
||||
this.layerOffsetXSmoothed.clear()
|
||||
this.parallaxBlendTSmoothed = 0
|
||||
this.parallaxBlendTSmoothed = prevParallaxBlendT
|
||||
for (const layer of this.layers) {
|
||||
this.renderState.layerOffsets.set(layer.id, { x: 0, y: 0 })
|
||||
this.renderState.layerOpacities.set(layer.id, layer.opacity)
|
||||
const ox = prevOffsetX.get(layer.id)
|
||||
if (ox !== undefined && Number.isFinite(ox)) {
|
||||
this.layerOffsetXSmoothed.set(layer.id, ox)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,6 +119,23 @@ export class LenticularEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内部倾斜状态与 displayGamma 立即对齐到给定 gamma(无渐进),用于离散档位首帧/校零后消除叠影与晃动。
|
||||
* @param {number} normalizedX
|
||||
* @param {{ resetLayerSmoothing?: boolean }} [opts]
|
||||
*/
|
||||
snapSimulatedTilt(normalizedX, opts = {}) {
|
||||
const g = clamp(normalizedX, -1, 1)
|
||||
const ts = Date.now()
|
||||
this.smoothedSensor = { gamma: g, beta: 0, timestamp: ts }
|
||||
this.displayGamma = g
|
||||
if (opts.resetLayerSmoothing === true) {
|
||||
this.parallaxBlendTSmoothed = 0
|
||||
this.layerOffsetXSmoothed.clear()
|
||||
}
|
||||
return this.computeRenderState()
|
||||
}
|
||||
|
||||
computeRenderState() {
|
||||
const sensitivity = this.physics.tiltSensitivity / 100
|
||||
const maxDisplacement = this.physics.parallaxDepth
|
||||
@ -111,7 +147,12 @@ export class LenticularEngine {
|
||||
|
||||
const stability = this.physics.angleStability / 100
|
||||
const smooth = this.physics.transitionSmoothness / 100
|
||||
const blendBase = 0.04 + smooth * 0.13 + stability * 0.15
|
||||
const blendBaseRaw = 0.04 + smooth * 0.13 + stability * 0.15
|
||||
const blendScale =
|
||||
this.physics.lenticularBlendBaseScale != null && Number.isFinite(this.physics.lenticularBlendBaseScale)
|
||||
? clamp(Number(this.physics.lenticularBlendBaseScale), 0.25, 2)
|
||||
: 1
|
||||
const blendBase = blendBaseRaw * blendScale
|
||||
|
||||
const shares = this.computeStripShares(N)
|
||||
const centers = []
|
||||
@ -180,14 +221,23 @@ export class LenticularEngine {
|
||||
|
||||
const weight = smoothedW[i]
|
||||
const isAnchor = i === 0
|
||||
const floor = isAnchor && N > 1 ? 0.18 : 0
|
||||
const finalOpacity = clamp(Math.max(floor, layer.opacity * weight), 0, 1)
|
||||
const defaultAnchorFloor = isAnchor && N > 1 ? 0.18 : 0
|
||||
const anchorFloor =
|
||||
this.physics.lenticularAnchorFloor != null && Number.isFinite(this.physics.lenticularAnchorFloor)
|
||||
? clamp(Number(this.physics.lenticularAnchorFloor), 0, 1)
|
||||
: defaultAnchorFloor
|
||||
const finalOpacity = clamp(Math.max(anchorFloor, layer.opacity * weight), 0, 1)
|
||||
this.renderState.layerOpacities.set(layer.id, finalOpacity)
|
||||
}
|
||||
|
||||
if (dominant >= 1) {
|
||||
const prev = this.layers[dominant - 1]
|
||||
const minGhost = PREV_LAYER_GHOST_MIN * prev.opacity
|
||||
const prevGhostMin =
|
||||
this.physics.lenticularPrevLayerGhostMin != null &&
|
||||
Number.isFinite(this.physics.lenticularPrevLayerGhostMin)
|
||||
? clamp(Number(this.physics.lenticularPrevLayerGhostMin), 0, 0.25)
|
||||
: PREV_LAYER_GHOST_MIN
|
||||
const minGhost = prevGhostMin * prev.opacity
|
||||
const cur = this.renderState.layerOpacities.get(prev.id) != null ? this.renderState.layerOpacities.get(prev.id) : 0
|
||||
const boosted = clamp(Math.max(cur, minGhost), 0, 1)
|
||||
this.renderState.layerOpacities.set(prev.id, boosted)
|
||||
@ -195,10 +245,15 @@ export class LenticularEngine {
|
||||
}
|
||||
|
||||
if (N >= 2) {
|
||||
const nonDomMin =
|
||||
this.physics.lenticularNonDominantResidualMin != null &&
|
||||
Number.isFinite(this.physics.lenticularNonDominantResidualMin)
|
||||
? clamp(Number(this.physics.lenticularNonDominantResidualMin), 0, 0.25)
|
||||
: NON_DOMINANT_RESIDUAL_MIN
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (i === dominant) continue
|
||||
const layer = this.layers[i]
|
||||
const minRes = NON_DOMINANT_RESIDUAL_MIN * layer.opacity
|
||||
const minRes = nonDomMin * layer.opacity
|
||||
const cur = this.renderState.layerOpacities.get(layer.id) != null ? this.renderState.layerOpacities.get(layer.id) : 0
|
||||
this.renderState.layerOpacities.set(layer.id, clamp(Math.max(cur, minRes), 0, 1))
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user