diff --git a/frontend/composables/useLenticularStudioTilt.js b/frontend/composables/useLenticularStudioTilt.js index 5083523..f69a68e 100644 --- a/frontend/composables/useLenticularStudioTilt.js +++ b/frontend/composables/useLenticularStudioTilt.js @@ -169,7 +169,7 @@ function uvForPlane(nx, ny, nz, mode) { /** * @param {object} opts * @param {(x: number, y: number) => void} opts.simulate - * @param {(tiltMagDeg: number) => void} [opts.simulateFromSignedDegrees] 若提供:用相对进入时基准的**倾角标量(度)**驱动预览(由页面做离散档位等);原生/加速度计侧会喂入非负幅度为主 + * @param {(tiltMagDeg: number, signedDegHint?: number) => void} [opts.simulateFromSignedDegrees] 若提供:用相对进入时基准的**倾角标量(度)**驱动预览(由页面做离散档位等);可选第二参为有符号倾角(度),加速度计路径可传以平衡两侧跟手 * @param {import('vue').Ref} opts.gyroSourceLabel * @param {boolean} [opts.useStudioAccelDirect] 光栅工作室:加速度计用重力投影直连(免原 warmup) * @param {() => void} [opts.onTiltDriverFallback] 从原生陀螺失败/超时回退到加速度计时调用(用于重置离散档位等 UI 状态) @@ -194,6 +194,8 @@ export function useLenticularStudioTilt(opts) { let nativeBase = { x: 0, y: 0, z: 0 } let nativeBaselineReady = false let nativeSmoothed = 0 + /** 离散档位(simulateFromSignedDegrees)下对 max(|Δ|) 的轻低通,减轻三轴取 max 的瞬时毛刺 */ + let nativeDiscreteDegLp = 0 /** 连续模式下与基线样本推断的主倾角轴(0=x,1=y,2=z),避免每帧在 x/y 间抢主导 */ let nativeLockedAxisIdx = 1 /** 递增以丢弃 stop 之后的原生回调 / 延时任务 */ @@ -205,6 +207,8 @@ export function useLenticularStudioTilt(opts) { let studioAccelBaselineRads = [] /** 加速度计直连模式下「校零」后的基准(弧度),仅与 simulateFromSignedDegrees 联用 */ let studioAccelBaseRad = null + /** 加速度计直连 + 离散档位:与原生侧一致的轻低通(度),避免切到加速度计后手感突变 */ + let studioAccelDiscreteDegLp = 0 let accelHandler = null let accelSmoothed = 0 @@ -241,12 +245,14 @@ export function useLenticularStudioTilt(opts) { nativeBase = { x: 0, y: 0, z: 0 } nativeBaselineReady = false nativeSmoothed = 0 + nativeDiscreteDegLp = 0 nativeLockedAxisIdx = 1 } function resetStudioAccelBaseline() { studioAccelBaseRad = null studioAccelBaselineRads = [] + studioAccelDiscreteDegLp = 0 } function accelToRelativeTilt01(ax, ay, az) { @@ -397,7 +403,11 @@ export function useLenticularStudioTilt(opts) { while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI const signedDeg = delta * (180 / Math.PI) - simulateFromSignedDegrees(Math.abs(signedDeg)) + const rawAbs = Math.abs(signedDeg) + const prevLp = studioAccelDiscreteDegLp + const kLp = rawAbs >= prevLp ? 0.27 : 0.4 + studioAccelDiscreteDegLp += (rawAbs - studioAccelDiscreteDegLp) * kLp + simulateFromSignedDegrees(studioAccelDiscreteDegLp, signedDeg) } else { const tiltRaw = Math.atan2(-ax, az) / (Math.PI / 5) const tilt01 = Math.max(-1, Math.min(1, tiltRaw)) @@ -488,7 +498,11 @@ export function useLenticularStudioTilt(opts) { const dy = deltaDeg(vy, nativeBase.y) const dz = deltaDeg(vz, nativeBase.z) if (typeof simulateFromSignedDegrees === 'function') { - simulateFromSignedDegrees(maxAbsTiltDeltaDeg(dx, dy, dz)) + const rawMag = maxAbsTiltDeltaDeg(dx, dy, dz) + const prevLp = nativeDiscreteDegLp + const kLp = rawMag >= prevLp ? 0.28 : 0.38 + nativeDiscreteDegLp += (rawMag - nativeDiscreteDegLp) * kLp + simulateFromSignedDegrees(nativeDiscreteDegLp) } else { const axisDeltas = [dx, dy, dz] const chosen = axisDeltas[nativeLockedAxisIdx] ?? pickDominantTiltDelta(dx, dy, dz) diff --git a/frontend/pages/castlove/lenticular-studio.vue b/frontend/pages/castlove/lenticular-studio.vue index ce8df9f..6821567 100644 --- a/frontend/pages/castlove/lenticular-studio.vue +++ b/frontend/pages/castlove/lenticular-studio.vue @@ -91,26 +91,83 @@ const { physics, layerTransforms, simulate, relax, snapSimulatedTilt } = useLent 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),对称跟随:进档/退档节奏一致,避免「抬手慢、压手快」的忽快忽慢。 + * dt 按实际回调间隔估算,与陀螺轮询/加速度计频率解耦。 + */ +const STUDIO_TILT_MAG_TAU_MS = 150 +/** 平滑后倾角幅度最大爬升速率(度/秒),抑制某一侧翻转时 raw 陡增导致的瞬间跳档 */ +const STUDIO_TILT_MAG_MAX_RISE_DPS = 82 +/** 回落可略快,减档仍跟手 */ +const STUDIO_TILT_MAG_MAX_FALL_DPS = 168 +/** + * 有符号倾角相对水平远离的速率阈值(度/秒);超过则认为该帧在「快速掰离水平」, + * 再收紧爬升限幅(典型:从下往上翻时 |atan2| 一侧梯度更大)。 + */ +const STUDIO_TILT_AWAY_FROM_LEVEL_DPS = 72 +/** 触发「快速远离水平」时,爬升限幅乘数(越小越匀) */ +const STUDIO_TILT_RISE_TIGHTEN = 0.42 /** 进入页后延迟开启倾斜监听(ms),避免首帧/基线采集/大图解码时画面跟着抖 */ const STUDIO_TILT_START_DELAY_MS = 560 const discreteStableStep = ref(0) -/** 与离散档位同步平滑的倾角幅度(度),换图时缓变避免观感断裂 */ +/** 与离散档位同步平滑的倾角幅度(度) */ let studioTiltMagEma = 0 +/** 上次幅度采样时间戳,用于按真实 dt 做指数平滑 */ +let lastStudioTiltMagSampleAt = 0 +/** 上一帧有符号倾角(度),仅加速度计路径传入;用于检测快速远离水平 */ +let lastStudioSignedDegHint = null + +function resetStudioTiltMagFilter() { + studioTiltMagEma = 0 + lastStudioTiltMagSampleAt = 0 + lastStudioSignedDegHint = null +} /** * 将相对基准的倾角标量(度,非负)映射为 LenticularEngine 的 gamma。 - * @param {number} tiltMagDeg 相对校零的倾角幅度(度);由 composable 已做低通时可为非负标量 + * @param {number} tiltMagDeg 相对校零的倾角幅度(度,非负) + * @param {number} [signedDegHint] 有符号倾角(度),仅加速度计路径传入;用于识别快速远离水平的一侧并收紧爬升 */ -function simulateFromSignedDegrees(tiltMagDeg) { +function simulateFromSignedDegrees(tiltMagDeg, signedDegHint) { const ls = layers.value || [] const n = Math.max(1, ls.length) 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 now = Date.now() + let dt = 33 + if (lastStudioTiltMagSampleAt > 0) { + dt = now - lastStudioTiltMagSampleAt + } + lastStudioTiltMagSampleAt = now + dt = Math.max(16, Math.min(100, dt)) + const alpha = 1 - Math.exp(-dt / STUDIO_TILT_MAG_TAU_MS) + let nextEma = studioTiltMagEma + (raw - studioTiltMagEma) * alpha + + const sec = dt * 0.001 + let maxRise = STUDIO_TILT_MAG_MAX_RISE_DPS * sec + const maxFall = STUDIO_TILT_MAG_MAX_FALL_DPS * sec + + if (Number.isFinite(signedDegHint)) { + if (lastStudioSignedDegHint != null && sec > 1e-6) { + const dAbsSignedDt = + (Math.abs(signedDegHint) - Math.abs(lastStudioSignedDegHint)) / sec + if (dAbsSignedDt > STUDIO_TILT_AWAY_FROM_LEVEL_DPS) { + maxRise *= STUDIO_TILT_RISE_TIGHTEN + } + } + lastStudioSignedDegHint = signedDegHint + } else { + lastStudioSignedDegHint = null + } + + let dMag = nextEma - studioTiltMagEma + if (dMag > 0) { + dMag = Math.min(dMag, maxRise) + } else { + dMag = Math.max(dMag, -maxFall) + } + studioTiltMagEma += dMag + const absDeg = studioTiltMagEma const STEP = STUDIO_DISCRETE_STEP_DEG const MARGIN = DISCRETE_STEP_HYST_DEG @@ -126,10 +183,8 @@ 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 }) - } + /* 不在此 snap:保留引擎内 displayGamma 渐近(angleStability / transitionSmoothness), + * 切换档位时 u 连续扫过叠化带,才能看到一张渐隐、另一张渐显。 */ } const { start: startTilt, stop: stopTilt, recalibrate: recalibrateTilt } = useLenticularStudioTilt({ @@ -139,14 +194,14 @@ const { start: startTilt, stop: stopTilt, recalibrate: recalibrateTilt } = useLe useStudioAccelDirect: true, onTiltDriverFallback: () => { discreteStableStep.value = 0 - studioTiltMagEma = 0 + resetStudioTiltMagFilter() }, }) /** 首屏与换素材后:档位与 EMA 归零,并写入与 0° 档位一致的 gamma(传感器未开时保持静止) */ function lockStudioPreviewStill() { discreteStableStep.value = 0 - studioTiltMagEma = 0 + resetStudioTiltMagFilter() simulateFromSignedDegrees(0) snapSimulatedTilt({ resetLayerSmoothing: true }) } @@ -171,7 +226,7 @@ function onMore() { const p = typeof raw === 'string' ? JSON.parse(raw) : raw layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '') discreteStableStep.value = 0 - studioTiltMagEma = 0 + resetStudioTiltMagFilter() nextTick(() => relax(0.86)) uni.showToast({ title: '已恢复', icon: 'none' }) } catch (e) { @@ -229,7 +284,7 @@ function resetLayer(layerId) { function onRecalibrate() { discreteStableStep.value = 0 - studioTiltMagEma = 0 + resetStudioTiltMagFilter() recalibrateTilt() nextTick(() => { simulateFromSignedDegrees(0) @@ -248,17 +303,18 @@ onMounted(() => { } const p = typeof raw === 'string' ? JSON.parse(raw) : raw layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '') - /* 光栅感:略抬残影/ghost + 叠化带宽;开启轻量视差,换图时另一张轮廓更易分辨 */ + /* 光栅感:略抬残影/ghost;叠化带宽略加宽,配合非 snap 的 gamma 渐近,换图时渐隐渐显更明显 */ Object.assign(physics, { - angleStability: 40, - transitionSmoothness: 28, + /* 略提高:让 smoothedSensor → displayGamma 在档位间多走几帧,叠化更明显 */ + angleStability: 52, + transitionSmoothness: 40, tiltSensitivity: 96, sensorDeadzoneStrength: 0, parallaxDepth: 18, lenticularAnchorFloor: 0.1, lenticularNonDominantResidualMin: 0.092, lenticularPrevLayerGhostMin: 0.098, - lenticularBlendBaseScale: 0.95, + lenticularBlendBaseScale: 1.1, }) } catch (e) { console.error('[lenticular-studio] load payload', e)