refactor(craft-tilt): 删除硬件传感器模块 + 同步清理 3 个其他页面

- 删 useLenticularStudioTilt.js (28KB, 894 行)
- 删 useOneEuroFilter.js (75 行)
- useLenticularCraftTiltPreview 移除对二者的引用与 3 个旧 API(scheduleTiltStart/stopTiltPreview/gyroSourceLabel)
- 同步清理 3 个其他消费方页面(discover/generation-result, castlove/success, asset-detail):
  - 删 3 个旧 destructure 键
  - 模板 :gyro-source 改静态 'simulation' + 删 :skip-built-in-touch
  - 删 12 处旧 API 调用
- 保留手动模拟入口 simulateTilt / simulateTiltFromNormalized / simulateTiltReset(Task 1)
- ★ 3 个页面用触摸事件替代硬件传感器(未加 slider,后续可补)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zerosaturation 2026-07-06 18:48:09 +08:00
parent 34dd301a92
commit a4836605e7
6 changed files with 6 additions and 1005 deletions

View File

@ -1,16 +1,12 @@
/** /**
* 铸爱光栅预览lenticular-result / asset-detail 物理参数 + 陀螺仪倾斜 离散档位映射 * 铸爱光栅预览lenticular-result / asset-detail 手动倾斜入口 离散档位映射
* *
* 引擎是连续叠化引擎gamma -1..+1 层组叠化但上层做硬切 * 引擎是连续叠化引擎gamma -1..+1 层组叠化但上层做硬切
* 1. 陀螺仪传感器 filtered (dx, dy) 2D 向量 * 1. 手动入口滑块 / 触摸 归一化 -1..+1 度数
* 2. (dx, dy) 2D 幅值 + 方向 离散 layerIdx硬阈值 + 滞回 + 冷却 * 2. (dx, dy) 2D 幅值 + 方向 离散 layerIdx硬阈值 + 滞回 + 冷却
* 3. layerIdx gamma ±1/0 engine + snapSimulatedTilt 重置平滑 * 3. layerIdx gamma ±1/0 engine + snapSimulatedTilt 重置平滑
*
* 传感器数据处理见 useLenticularStudioTilt.js跳变拒绝 / 快慢通道 / 符号滞回
*/ */
import { ref, nextTick } from 'vue'
import { useLenticularPreview } from '@/composables/useLenticularPreview.js' import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
import { useLenticularStudioTilt } from '@/composables/useLenticularStudioTilt.js'
// 手动模拟倾斜:归一化 → 度数 转换系数spec §3.5 // 手动模拟倾斜:归一化 → 度数 转换系数spec §3.5
const NORMALIZED_TO_DEG = 45 const NORMALIZED_TO_DEG = 45
@ -48,7 +44,6 @@ const dlog = (...args) => {
* @param {import('vue').Ref<Array>|import('vue').Ref<unknown>} layersRef * @param {import('vue').Ref<Array>|import('vue').Ref<unknown>} layersRef
*/ */
export function useLenticularCraftTiltPreview(layersRef) { export function useLenticularCraftTiltPreview(layersRef) {
const gyroSourceLabel = ref('simulation')
const { physics, layerTransforms, simulate, relax, snapSimulatedTilt } = useLenticularPreview(layersRef) const { physics, layerTransforms, simulate, relax, snapSimulatedTilt } = useLenticularPreview(layersRef)
Object.assign(physics, FLOW_PHYSICS) Object.assign(physics, FLOW_PHYSICS)
@ -176,45 +171,6 @@ export function useLenticularCraftTiltPreview(layersRef) {
simulate(0, 0) simulate(0, 0)
} }
const { start: startTilt, stop: stopTilt } = useLenticularStudioTilt({
simulate,
simulateFromSignedDegrees,
gyroSourceLabel,
onTiltDriverFallback: () => {
lastLayerIdx = -1
dirSign = 0
dirOppositeStreak = 0
},
})
let entryTiltTimer = null
function cancelScheduledTiltStart() {
if (entryTiltTimer != null) {
clearTimeout(entryTiltTimer)
entryTiltTimer = null
}
}
function scheduleTiltStart() {
dlog('[scheduleTiltStart] called')
cancelScheduledTiltStart()
nextTick(() => {
relax(0.86)
lockPreviewStill()
dlog('[scheduleTiltStart] timer set, delay:', LENTICULAR_TILT_START_DELAY_MS)
entryTiltTimer = setTimeout(() => {
entryTiltTimer = null
startTilt()
}, LENTICULAR_TILT_START_DELAY_MS)
})
}
function stopTiltPreview() {
cancelScheduledTiltStart()
stopTilt()
}
// ==================================================================== // ====================================================================
// 手动模拟倾斜入口spec §3.1 // 手动模拟倾斜入口spec §3.1
// ==================================================================== // ====================================================================
@ -253,9 +209,6 @@ export function useLenticularCraftTiltPreview(layersRef) {
layerTransforms, layerTransforms,
simulate, simulate,
relax, relax,
gyroSourceLabel,
scheduleTiltStart,
stopTiltPreview,
lockPreviewStill, lockPreviewStill,
// 新增手动模拟入口spec §3.1 // 新增手动模拟入口spec §3.1
simulateTilt, simulateTilt,

View File

@ -1,840 +0,0 @@
/**
* 光栅卡倾斜驱动铸爱预览共用
*
* 多路径混合按优先级
* - App AndroidNative.js SensorManager加速度计低通滤波 重力方向 绝对倾角
* - App iOSNative.js CMMotionManager.deviceMotion.attitude系统级传感器融合
* - H5DeviceOrientationEventChrome/Safari/Firefox 通用
* - 小程序降级为模拟无陀螺仪 API
*
* uni-app 内置 uni.startGyroscope / uni.onGyroscopeChange Android/iOS
* 原生层未实现桥接文档里写着但底层没接通实测所有 3.x 版本 App 打包后调用均
* 静默失败无报错无日志无回调官方跳过陀螺仪是因为 iOS CMMotionManager
* Android SensorManager 在生命周期权限模型回调线程上差异太大
* 结论App 端必须用 Native.js 或原生插件接入
*
* 平台接入要点
* - AndroidBODY_SENSORS 权限Android 12+ 强制 ACCESS_FINE_LOCATION 不够
* - iOSNSMotionUsageDescriptionInfo.plist否则 CMMotionManager 直接失败
* - 不能在 onLoad 阶段调用传感器须等 plus.ready
* - 务必检查传感器硬件getDefaultSensor(TYPE_GYROSCOPE) 返回 null = 不支持
*
* DeviceOrientationEvent
* - gamma: 左右倾斜 -90°~+90°平放=0
* - beta: 前后倾斜 -180°~+180°竖直=0
* - iOS 13+ Safari requestPermission用户手势触发
*
* 关键设计
* 1. 跳变拒绝abs(raw-prev) > 15° clamp掐掉位移加速度伪影
* 2. 快通道 α=0.25 0.2s 63%离散切换更跟手
* 3. 慢通道 α=0.08 + 冷启动基线=0自适应用户"休息位"
* 4. dx/dy = fast - slow 上层 2D 挡位映射
* 5. 跳过前 5 帧预热帧传感器初始化噪声
*/
import { createOneEuroFilter, nowMs } from '@/composables/useOneEuroFilter.js'
const JUMP_REJECT_DEG = 15
const SKIP_WARMUP_FRAMES = 5
const FAST_ALPHA = 0.25
const SLOW_ALPHA = 0.08
/**
* @param {object} opts
* @param {(x: number, y: number) => void} opts.simulate
* @param {(dx: number, dy: number) => void} [opts.simulateFromSignedDegrees]
* @param {import('vue').Ref<string>} opts.gyroSourceLabel
* @param {() => void} [opts.onTiltDriverFallback]
*/
export function useLenticularStudioTilt(opts) {
const { simulate, simulateFromSignedDegrees, gyroSourceLabel, onTiltDriverFallback } = opts
// ====================================================================
// 通用状态
// ====================================================================
let tiltGen = 0
// 跳变拒绝:上一帧 rawNaN 表示"无条件接受第一帧"
let prevRawX = NaN, prevRawY = NaN
// 预热跳过计数器
let warmupSkip = 0
// 快通道:平滑跟踪(α=0.25 跟手 + 抑制噪声)
let fastX = NaN, fastY = NaN
// 慢通道:缓慢追踪"休息位"α=0.08 自动重居中,冷启动基线=0
let slowX = NaN, slowY = NaN
// 日志节流
let lastLogSecond = 0
// ====================================================================
// Native.js 路径状态
// ====================================================================
// #ifdef APP-PLUS
let nativeCleanup = null // () => void
let nativeStartTimer = null // setTimeout ID
// #endif
// ====================================================================
// DeviceOrientation 路径状态
// ====================================================================
let orientationHandler = null
// ——— 通用复位 ———
function resetState() {
prevRawX = NaN; prevRawY = NaN
warmupSkip = 0
fastX = NaN; fastY = NaN
slowX = NaN; slowY = NaN
}
// ——— 跳变拒绝 ———
/**
* 单帧 raw 变化超过 JUMP_REJECT_DEG clamp掐掉位移加速度伪影
* 真实倾斜不太可能超过 ±15°/大跳变必定是设备平移加速度的伪信号
*/
function clampJump(rawX, rawY) {
let cx = rawX, cy = rawY
if (Number.isFinite(prevRawX)) {
if (Math.abs(rawX - prevRawX) > JUMP_REJECT_DEG) {
cx = prevRawX + Math.sign(rawX - prevRawX) * JUMP_REJECT_DEG
}
if (Math.abs(rawY - prevRawY) > JUMP_REJECT_DEG) {
cy = prevRawY + Math.sign(rawY - prevRawY) * JUMP_REJECT_DEG
}
}
prevRawX = rawX; prevRawY = rawY
return { cx, cy }
}
// ——— 快慢双通道 ———
function updateFast(rawX, rawY) {
if (!Number.isFinite(fastX)) { fastX = rawX; fastY = rawY; return }
fastX += (rawX - fastX) * FAST_ALPHA
fastY += (rawY - fastY) * FAST_ALPHA
}
function updateSlow(rawX, rawY) {
if (!Number.isFinite(slowX)) {
// 冷启动基线=0让第1帧 dx 立即有值(设备已倾斜时不会白板)
slowX = 0; slowY = 0
return
}
slowX += (rawX - slowX) * SLOW_ALPHA
slowY += (rawY - slowY) * SLOW_ALPHA
}
// ——— 统一输出 ———
function applyDelta(dx, dy) {
// 节流日志(每秒最多一条)
if (Math.floor(Date.now() / 1000) !== lastLogSecond) {
lastLogSecond = Math.floor(Date.now() / 1000)
console.log('[StudioTilt] dx:', dx.toFixed(1), 'dy:', dy.toFixed(1),
'src:', gyroSourceLabel.value)
}
if (typeof simulateFromSignedDegrees === 'function') {
simulateFromSignedDegrees(dx, dy)
} else {
simulate(0, 0)
}
}
// ====================================================================
// Native.js 倾斜帧入口Android & iOS 共用)
// 参数:(rollDeg, pitchDeg) — 绝对倾角,单位为度
// rollDeg: 左右倾斜(>0 右侧低),映射到 dx
// pitchDeg: 前后倾斜(>0 后端低),映射到 dy
// ====================================================================
function handleNativeTiltFrame(rollDeg, pitchDeg) {
if (warmupSkip < SKIP_WARMUP_FRAMES) {
warmupSkip++
return
}
const { cx, cy } = clampJump(rollDeg, pitchDeg)
updateFast(cx, cy)
updateSlow(cx, cy)
const dx = fastX - slowX
const dy = fastY - slowY
applyDelta(dx, dy)
}
// ====================================================================
// 路径 A-1: Native.js Android
// 用加速度计 + 低通滤波提取重力方向 → 绝对 roll/pitch
// 避免直接积分陀螺仪TYPE_GYROSCOPE 输出角速度 rad/s积分漂移严重
// ====================================================================
// #ifdef APP-PLUS
// ================================================================
// 插件路径 (Android)imengyu-UniAndroidGyro-GyroModule
// 插件内部完成 Java float[] → JS number 转换,绕开 Native.js 限制
// ================================================================
const NATIVE_PLUGIN_ID = 'imengyu-UniAndroidGyro-GyroModule'
const POLL_INTERVAL_MS = 33
const RESTART_DELAY_MS = 600
const STARTGYRO_MAX_RETRIES = 3
const STARTGYRO_BASE_RETRY_MS = 400
let gyroModule = null
let gyroPollGen = 0
let pollTimer = null
let startTimer = null
function tryRequireGyroModule() {
try {
if (typeof uni !== 'undefined' && typeof uni.requireNativePlugin === 'function') {
const mod = uni.requireNativePlugin(NATIVE_PLUGIN_ID)
if (mod && typeof mod.startGyro === 'function' && typeof mod.stopGyro === 'function') {
return mod
}
}
} catch (e) {
console.warn('[useLenticularStudioTilt] requireNativePlugin failed', e)
}
return null
}
function stopPoll() {
if (pollTimer != null) {
try { clearInterval(pollTimer) } catch (_) {}
pollTimer = null
}
if (startTimer != null) {
try { clearTimeout(startTimer) } catch (_) {}
startTimer = null
}
}
function handleGyroValue(res) {
const rawX = Number(res.y || 0) // roll → dx左右
const rawY = Number(res.x || 0) // pitch → dy前后
if (warmupSkip < SKIP_WARMUP_FRAMES) {
warmupSkip++
return
}
const { cx, cy } = clampJump(rawX, rawY)
updateFast(cx, cy)
updateSlow(cx, cy)
const dx = fastX - slowX
const dy = fastY - slowY
applyDelta(dx, dy)
}
function pollOnce(myGen) {
if (myGen !== gyroPollGen || !gyroModule) return
try {
gyroModule.getGyroValue((res) => {
if (myGen !== gyroPollGen || !gyroModule || !res) return
gyroSourceLabel.value = 'gyroscope'
handleGyroValue(res)
})
} catch (_) {}
}
function tryStartGyro(myGen, retry) {
if (myGen !== gyroPollGen || !gyroModule) return
if (retry > 0) {
console.log('[useLenticularStudioTilt] startGyro retry #', retry)
}
gyroModule.startGyro({ interval: 'ui' }, (res) => {
if (myGen !== gyroPollGen || !gyroModule) return
if (!res || !res.success) {
const errMsg = (res && res.errMsg) || ''
if (retry < STARTGYRO_MAX_RETRIES && /running/i.test(errMsg)) {
const delay = STARTGYRO_BASE_RETRY_MS * (retry + 1)
console.warn('[useLenticularStudioTilt] startGyro busy, retry in', delay, 'ms')
startTimer = setTimeout(() => {
startTimer = null
tryStartGyro(myGen, retry + 1)
}, delay)
return
}
console.warn('[useLenticularStudioTilt] startGyro failed:', res)
onNativeFallback()
return
}
console.log('[useLenticularStudioTilt] plugin gyro ok, polling')
gyroSourceLabel.value = 'gyroscope'
warmupSkip = 0
pollOnce(myGen)
pollTimer = setInterval(() => pollOnce(myGen), POLL_INTERVAL_MS)
})
}
function stopGyroPlugin() {
gyroPollGen++
stopPoll()
if (gyroModule && typeof gyroModule.stopGyro === 'function') {
try { gyroModule.stopGyro(() => {}) } catch (_) {}
}
gyroModule = null
}
// ================================================================
// 原生传感器 (iOS / Android 备用)
// ================================================================
/**
* @param {(rollDeg: number, pitchDeg: number) => void} onFrame
* @returns {{ cleanup: () => void } | null}
*/
function createAndroidTiltSensor(onFrame) {
try {
if (typeof plus === 'undefined' || !plus.android) {
console.warn('[useLenticularStudioTilt] plus.android not available')
return null
}
const Context = plus.android.importClass('android.content.Context')
const SensorManager = plus.android.importClass('android.hardware.SensorManager')
const Sensor = plus.android.importClass('android.hardware.Sensor')
const activity = plus.android.runtimeMainActivity()
const sm = activity.getSystemService(Context.SENSOR_SERVICE)
// 先检查陀螺仪硬件是否存在(作为诊断日志,实际用加速度计)
const gyroSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
if (!gyroSensor) {
console.warn('[useLenticularStudioTilt] device has no gyroscope hardware — ' +
'using accelerometer gravity fallback')
} else {
console.log('[useLenticularStudioTilt] gyroscope hardware detected; using accelerometer gravity for absolute tilt')
}
// 检查加速度计
const gravitySensor = sm.getDefaultSensor(Sensor.TYPE_GRAVITY)
const accelSensorRaw = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
console.log('[useLenticularStudioTilt] Android sensor support: ' +
'gyro=' + (gyroSensor ? 'YES' : 'NO') + ', ' +
'gravity=' + (gravitySensor ? 'YES' : 'NO') + ', ' +
'accel=' + (accelSensorRaw ? 'YES' : 'NO'))
// TYPE_GRAVITY 直接输出重力分量,优先使用;不支持则用 TYPE_ACCELEROMETER + LPF
let accelSensor = gravitySensor
const useGravitySensor = !!accelSensor
if (!accelSensor) {
accelSensor = accelSensorRaw
console.log('[useLenticularStudioTilt] TYPE_GRAVITY unavailable, using TYPE_ACCELEROMETER + LPF')
} else {
console.log('[useLenticularStudioTilt] using TYPE_GRAVITY sensor (no LPF needed)')
}
if (!accelSensor) {
console.warn('[useLenticularStudioTilt] no accelerometer available — tilt NOT supported on this device')
return null
}
// 低通滤波状态(仅 TYPE_ACCELEROMETER 需要)
const LPF_ALPHA = 0.85
let gx = 0, gy = 0, gz = 0
let gravityReady = useGravitySensor // TYPE_GRAVITY 不需要 LPF
let sensorCallbackCount = 0
let ArrayReflect = null
const listener = plus.android.implements('android.hardware.SensorEventListener', {
onSensorChanged: function(event) {
sensorCallbackCount++
try {
// ——— 诊断:探明 event 对象的实际结构 ———
if (sensorCallbackCount <= 3) {
// 枚举 event 上的所有 key
let keys = []
try {
for (let k in event) {
try { keys.push(k + ':' + typeof event[k]) } catch (_) { keys.push(k + ':err') }
}
} catch (_) {}
console.log('[useLenticularStudioTilt] onSensorChanged #' + sensorCallbackCount +
' typeof=' + typeof event +
' keys=[' + (keys.length ? keys.join(', ') : '(none)') + ']')
// 尝试 plusGetAttribute 方式(项目既有模式)
try {
const v = event.plusGetAttribute('values')
console.log('[useLenticularStudioTilt] plusGetAttribute("values"):', typeof v, v)
} catch (e1) {
console.log('[useLenticularStudioTilt] plusGetAttribute("values") threw:', e1)
}
// 尝试 plus.android.getAttribute
try {
const v = plus.android.getAttribute(event, 'values')
console.log('[useLenticularStudioTilt] getAttribute(event,"values"):', typeof v, v)
} catch (e2) {
console.log('[useLenticularStudioTilt] getAttribute(event,"values") threw:', e2)
}
}
// ——— 获取 float[] 数据 ———
// 已验证:
// ✅ plusGetAttribute('values') → Java float[]
// ✅ Array.get(arr, i) → Java Float 对象(之前 r0 type=object 是成功的)
// ❌ Array.getFloat(arr, i) → null
// ❌ Number(Float) → 0
// ❌ Float.floatValue 不是 JS function
// 取 float[] 数据 — 内联调用不经过函数传参Native.js 传参丢引用)
let v0, v1, v2
let rawValues = null
try { rawValues = event.plusGetAttribute('values') } catch (_) {}
if (!rawValues) {
try { rawValues = plus.android.getAttribute(event, 'values') } catch (_) {}
}
if (!rawValues) return
if (!ArrayReflect || typeof ArrayReflect.get !== "function") {
try {
ArrayReflect = plus.android.importClass("java.lang.reflect.Array")
} catch (_) {}
}
// 内联 Array.get()(已验证可用),拿 Float → invoke floatValue 拆箱
if (sensorCallbackCount <= 2) { console.log('[useLenticularStudioTilt] pre-get: ArrayReflect=' + typeof ArrayReflect + ' hasGet=' + (ArrayReflect && typeof ArrayReflect.get === 'function') + ' rv=' + typeof rawValues + ' rvNull=' + (rawValues == null)) }
const f0 = ArrayReflect ? ArrayReflect.get(rawValues, 0) : null
const f1 = ArrayReflect ? ArrayReflect.get(rawValues, 1) : null
const f2 = ArrayReflect ? ArrayReflect.get(rawValues, 2) : null
if (sensorCallbackCount <= 2) {
console.log('[useLenticularStudioTilt] Array.get results: f0=' + typeof f0 +
' null=' + (f0 == null) + ' f1 null=' + (f1 == null) + ' f2 null=' + (f2 == null))
}
function unboxFloat(f) {
if (f == null) return NaN
try {
const v = plus.android.invoke(f, 'floatValue')
if (typeof v === 'number') return v
} catch (_) {}
try {
const s = plus.android.invoke(f, 'toString')
if (s) return parseFloat(String(s))
} catch (_) {}
return NaN
}
v0 = unboxFloat(f0)
v1 = unboxFloat(f1)
v2 = unboxFloat(f2)
if (typeof v0 !== 'number' || isNaN(v0)) {
if (sensorCallbackCount <= 3) {
console.warn('[useLenticularStudioTilt] onSensorChanged: failed to read values #' + sensorCallbackCount)
}
return
}
// 第一次拿到值时打印
if (sensorCallbackCount <= 5) {
console.log('[useLenticularStudioTilt] sensor #' + sensorCallbackCount +
': [' + v0.toFixed(3) + ', ' + v1.toFixed(3) + ', ' + v2.toFixed(3) + ']')
}
let rx, ry, rz
if (useGravitySensor) {
// TYPE_GRAVITY 直接输出重力分量
rx = v0
ry = v1
rz = v2
} else {
// TYPE_ACCELEROMETER低通滤波提取重力分量
gx = LPF_ALPHA * gx + (1 - LPF_ALPHA) * v0
gy = LPF_ALPHA * gy + (1 - LPF_ALPHA) * v1
gz = LPF_ALPHA * gz + (1 - LPF_ALPHA) * v2
if (!gravityReady) {
// 等重力滤波器收敛后再输出
const mag = Math.sqrt(gx * gx + gy * gy + gz * gz)
if (mag > 8 && mag < 12) gravityReady = true
return
}
rx = gx; ry = gy; rz = gz
}
// 重力方向 → roll/pitch坐标系对齐 DeviceOrientation
// roll: 绕 z 轴旋转(左右倾斜)
// pitch: 绕 x 轴旋转(前后倾斜)
const roll = Math.atan2(rx, rz) * (180 / Math.PI)
const pitch = Math.atan2(ry, Math.sqrt(rx * rx + rz * rz)) * (180 / Math.PI)
onFrame(roll, pitch)
} catch (e) {
if (sensorCallbackCount <= 3) {
console.warn('[useLenticularStudioTilt] onSensorChanged error:', e, JSON.stringify(e))
}
}
},
onAccuracyChanged: function(sensor, accuracy) {}
})
// SENSOR_DELAY_GAME = 20,000μs (50Hz) — 响应快,够跟手
sm.registerListener(listener, accelSensor, SensorManager.SENSOR_DELAY_GAME)
return {
cleanup: function() {
sm.unregisterListener(listener)
}
}
} catch (e) {
console.warn('[useLenticularStudioTilt] Android tilt sensor init failed:', e)
return null
}
}
// ====================================================================
// 路径 A-2: Native.js iOS
// CMMotionManager.deviceMotion.attitude → roll/pitch
// 系统级传感器融合,无漂移,比手动积分陀螺仪可靠得多
// ====================================================================
/**
* @param {(rollDeg: number, pitchDeg: number) => void} onFrame
* @returns {{ cleanup: () => void } | null}
*/
function createIOSTiltSensor(onFrame) {
try {
if (typeof plus === 'undefined' || !plus.ios) {
console.warn('[useLenticularStudioTilt] plus.ios not available')
return null
}
const CMMotionManager = plus.ios.importClass('CMMotionManager')
const NSOperationQueue = plus.ios.importClass('NSOperationQueue')
const mm = CMMotionManager.alloc().init()
if (!mm || !mm.isDeviceMotionAvailable || !mm.isDeviceMotionAvailable()) {
console.warn('[useLenticularStudioTilt] CMMotionManager deviceMotion NOT available — tilt NOT supported on this device')
if (mm) { mm.release() }
return null
}
// 检查各传感器硬件可用性(诊断用)
const gyroOk = mm.isGyroAvailable && mm.isGyroAvailable()
const accelOk = mm.isAccelerometerAvailable && mm.isAccelerometerAvailable()
const magnetoOk = mm.isMagnetometerAvailable && mm.isMagnetometerAvailable()
console.log('[useLenticularStudioTilt] iOS sensor support: ' +
'deviceMotion=YES, ' +
'gyro=' + (gyroOk ? 'YES' : 'NO') + ', ' +
'accel=' + (accelOk ? 'YES' : 'NO') + ', ' +
'magnetometer=' + (magnetoOk ? 'YES' : 'NO'))
if (!gyroOk) {
console.warn('[useLenticularStudioTilt] iOS gyro unavailable; deviceMotion will fall back to accel+compass fusion')
}
// 30Hz 采样(适合倾斜交互,低于游戏需求但省电)
mm.setDeviceMotionUpdateInterval(1.0 / 30.0)
const queue = NSOperationQueue.mainQueue()
// 用 block 接收 motion 数据
// CMDeviceMotionHandler = void (^)(CMDeviceMotion * _Nullable motion, NSError * _Nullable error)
const handler = plus.ios.implements('CMDeviceMotionHandler', {
invoke: function(motion, error) {
try {
if (!motion || error) return
const attitude = motion.attitude()
if (!attitude) return
// attitude.roll / pitch 是弧度,转度
const roll = attitude.roll() * (180 / Math.PI)
const pitch = attitude.pitch() * (180 / Math.PI)
onFrame(roll, pitch)
} catch (_) {}
}
})
mm.startDeviceMotionUpdatesToQueueWithHandler(queue, handler)
return {
cleanup: function() {
mm.stopDeviceMotionUpdates()
mm.release()
}
}
} catch (e) {
console.warn('[useLenticularStudioTilt] iOS tilt sensor init failed:', e)
return null
}
}
/**
* 等待 plus 环境就绪后执行回调
* Vue mounted 阶段调用时 plus 通常已就绪但加一层防护
* @param {(ready: boolean) => void} cb
*/
function onPlusReady(cb) {
try {
if (typeof plus !== 'undefined') {
cb(true)
return
}
// 尚未就绪:挂载 plusready 事件
function handler() {
document.removeEventListener('plusready', handler)
cb(true)
}
document.addEventListener('plusready', handler, false)
} catch (_) {
cb(false)
}
}
function startNativeApp(myGen) {
onPlusReady(function(ready) {
if (!ready) {
console.warn('[useLenticularStudioTilt] plus not ready')
onNativeFallback()
return
}
// Androiduni.onAccelerometerChangeuni 原生桥接,不走 WebView 事件,
// 也不走 Native.js float[] 读取uni 内部处理数据转换,直接返 JS number
if (plus.os && plus.os.name === 'Android') {
uni.startAccelerometer({ interval: 'game' })
let accelWarmup = 0
// ——— Android-accel 路径专用One-Euro Filter速度自适应低通———
// 必须在闭包内新建,不能放在 handleNativeTiltFrame
// handleNativeTiltFrame 被 iOS CMMotionManager 共用)
const oneEuroRoll = createOneEuroFilter({ mincutoff: 0.2, beta: 0.05, dcutoff: 1.0 })
const oneEuroPitch = createOneEuroFilter({ mincutoff: 0.2, beta: 0.05, dcutoff: 1.0 })
let accelCb = function(res) {
if (myGen !== tiltGen) return
// 跳过前几帧初始化噪声
if (accelWarmup < SKIP_WARMUP_FRAMES) { accelWarmup++; return }
const rollRaw = Math.atan2(res.x, res.z) * (180 / Math.PI)
const pitchRaw = Math.atan2(res.y, Math.sqrt(res.x*res.x + res.z*res.z)) * (180 / Math.PI)
// One-Euro 平滑(解决静止抖动 + 减小动态延迟)
const t = nowMs()
const roll = oneEuroRoll.filter(rollRaw, t)
const pitch = oneEuroPitch.filter(pitchRaw, t)
gyroSourceLabel.value = 'accelerometer'
handleNativeTiltFrame(roll, pitch)
}
uni.onAccelerometerChange(accelCb)
nativeCleanup = function() {
uni.offAccelerometerChange(accelCb)
try { uni.stopAccelerometer() } catch (_) {}
}
console.log('[useLenticularStudioTilt] uni.onAccelerometerChange started with One-Euro Filter')
return
}
// iOSCMMotionManager.attitude
if (plus.os && plus.os.name === 'iOS') {
const result = createIOSTiltSensor(function(roll, pitch) {
if (myGen !== tiltGen) return
gyroSourceLabel.value = 'native-ios'
handleNativeTiltFrame(roll, pitch)
})
if (result) {
nativeCleanup = result.cleanup
console.log('[useLenticularStudioTilt] Native.js iOS tilt started')
return
}
}
// Native.js 不可用 → 降级
console.warn('[useLenticularStudioTilt] Native.js tilt init failed for ' +
(plus.os ? plus.os.name : 'unknown') + ' — falling back')
onNativeFallback()
})
}
function stopNativeApp() {
stopGyroPlugin()
if (nativeStartTimer != null) {
try { clearTimeout(nativeStartTimer) } catch (_) {}
nativeStartTimer = null
}
if (nativeCleanup) {
try { nativeCleanup() } catch (_) {}
nativeCleanup = null
}
}
// #endif
/**
* Native.js 路径降级回调转到 DeviceOrientation 或模拟
*/
function onNativeFallback() {
// #ifdef APP-PLUS
nativeCleanup = null
// #endif
// 尝试 DeviceOrientationEvent
console.log('[useLenticularStudioTilt] Native.js unavailable, trying DeviceOrientationEvent...')
if (startOrientation()) return
// 全部不可用 → 模拟
console.warn('[useLenticularStudioTilt] all tilt paths exhausted — falling back to simulation')
gyroSourceLabel.value = 'simulation'
simulate(0, 0)
if (typeof onTiltDriverFallback === 'function') {
try { onTiltDriverFallback() } catch (_) {}
}
}
// ====================================================================
// 路径 B: DeviceOrientationEvent (H5 / iOS App WKWebView 降级)
// ====================================================================
function handleDeviceOrientation(e) {
if (e.gamma == null) return
// gamma: 左右倾斜rollbeta: 前后倾斜pitch
const rawX = e.gamma || 0 // roll → dx左右
const rawY = e.beta || 0 // pitch → dy前后
if (warmupSkip < SKIP_WARMUP_FRAMES) {
warmupSkip++
return
}
const { cx, cy } = clampJump(rawX, rawY)
updateFast(cx, cy)
updateSlow(cx, cy)
const dx = fastX - slowX
const dy = fastY - slowY
applyDelta(dx, dy)
}
function startOrientationListener() {
gyroSourceLabel.value = 'deviceorientation'
warmupSkip = 0
orientationHandler = handleDeviceOrientation
window.addEventListener('deviceorientation', orientationHandler, true)
console.log('[useLenticularStudioTilt] deviceorientation listener started')
}
function stopOrientationListener() {
if (orientationHandler) {
window.removeEventListener('deviceorientation', orientationHandler, true)
orientationHandler = null
}
}
/**
* 尝试启动 DeviceOrientationEvent
* @returns {boolean} true=已启动或正在请求权限false=不可用
*/
function startOrientation() {
if (typeof DeviceOrientationEvent === 'undefined') {
console.warn('[useLenticularStudioTilt] DeviceOrientationEvent not supported')
return false
}
// iOS 13+ Safari 需要用户手势触发 requestPermission
if (typeof DeviceOrientationEvent.requestPermission === 'function') {
gyroSourceLabel.value = 'deviceorientation-requesting'
DeviceOrientationEvent.requestPermission()
.then(state => {
if (state === 'granted') {
startOrientationListener()
} else {
console.warn('[useLenticularStudioTilt] DeviceOrientation permission denied')
gyroSourceLabel.value = 'simulation'
simulate(0, 0)
if (typeof onTiltDriverFallback === 'function') {
try { onTiltDriverFallback() } catch (_) {}
}
}
})
.catch(err => {
console.warn('[useLenticularStudioTilt] DeviceOrientation permission error:', err)
gyroSourceLabel.value = 'simulation'
simulate(0, 0)
if (typeof onTiltDriverFallback === 'function') {
try { onTiltDriverFallback() } catch (_) {}
}
})
return true
}
// Android / 旧 iOS直接启动 listener
startOrientationListener()
return true
}
// ====================================================================
// 公开 API
// ====================================================================
/**
* 启动传感器优先级
* 1. Native.js (App Android/iOS) 直接桥接原生 API最可靠
* 2. DeviceOrientationEvent (H5 / iOS App WKWebView)
* 3. 降级模拟
*/
function start() {
console.log('[useLenticularStudioTilt] start()')
stop()
tiltGen++
resetState()
lastLogSecond = 0
// #ifdef APP-PLUS
// App 端:优先 Native.js
startNativeApp(tiltGen)
return
// #endif
// #ifndef APP-PLUS
// H5 / 小程序:尝试 DeviceOrientationEvent
if (startOrientation()) return
// 全部不可用 → 模拟
gyroSourceLabel.value = 'simulation'
simulate(0, 0)
if (typeof onTiltDriverFallback === 'function') {
try { onTiltDriverFallback() } catch (_) {}
}
// #endif
}
function stop() {
console.log('[useLenticularStudioTilt] stop()')
tiltGen++
// #ifdef APP-PLUS
stopNativeApp()
// #endif
stopOrientationListener()
gyroSourceLabel.value = 'simulation'
resetState()
}
function recalibrate() {
resetState()
simulate(0, 0)
}
return { start, stop, recalibrate }
}

View File

@ -1,75 +0,0 @@
/**
* One-Euro Filter速度自适应低通滤波器
* 参考Casiez et al., "1€ Filter: A Simple Speed-based Low-pass Filter for Noisy Input in Interactive Systems", CHI 2012
* https://cristal.univ-lille.fr/~casiez/1euro/
*
* 用法每个轴一个实例
* const f = createOneEuroFilter({ mincutoff: 0.2, beta: 0.05, dcutoff: 1.0 })
* const out = f.filter(x, performance.now())
* f.reset()
*
* @param {object} [opts]
* @param {number} [opts.mincutoff=0.2] - Hz静止截止频率
* @param {number} [opts.beta=0.05] - 速度系数越大快速运动越跟手
* @param {number} [opts.dcutoff=1.0] - Hz速度估计的截止频率
*/
export function createOneEuroFilter({ mincutoff = 0.2, beta = 0.05, dcutoff = 1.0 } = {}) {
let xPrev = null // 上次 FILTERED 输出(与 Casiez 论文一致)
let dxPrev = 0 // 平滑后的速度
let tPrev = null // 上次时间戳
function smoothingFactor(cutoff, dt) {
const r = 2 * Math.PI * cutoff * dt
return r / (r + 1)
}
function filter(x, t) {
// 首帧:直接接受,建立基线
if (xPrev == null || tPrev == null) {
xPrev = x
dxPrev = 0
tPrev = t
return x
}
const dt = Math.max((t - tPrev) / 1000, 1e-6) // ms → s
// 1. 速度差分(用 filtered xPrev与 Casiez 一致)
const dx = (x - xPrev) / dt
// 2. 平滑速度
const aD = smoothingFactor(dcutoff, dt)
const edx = aD * dx + (1 - aD) * dxPrev
// 3. 自适应截止频率
const cutoff = mincutoff + beta * Math.abs(edx)
// 4. 平滑位置
const a = smoothingFactor(cutoff, dt)
const result = a * x + (1 - a) * xPrev
xPrev = result
dxPrev = edx
tPrev = t
return result
}
function reset() {
xPrev = null
dxPrev = 0
tPrev = null
}
return { filter, reset }
}
/**
* 取单调递增的高精度时间戳毫秒
* 优先 performance.now()sub-ms 精度回退 Date.now()WebView ~15ms 精度
*/
export function nowMs() {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now()
}
return Date.now()
}

View File

@ -47,7 +47,7 @@
</image> </image>
<view v-if="isLenticularAsset" class="detail-lenticular-slot"> <view v-if="isLenticularAsset" class="detail-lenticular-slot">
<LenticularCard class="detail-lenticular-card" :layers="lenticularLayers" <LenticularCard class="detail-lenticular-card" :layers="lenticularLayers"
:transforms="layerTransforms" :gyro-source="gyroSourceLabel" :skip-built-in-touch="true" :transforms="layerTransforms" gyro-source="simulation"
tilt-hint-text="倾斜手机查看光栅效果" :shimmer-mid-opacity="0.16" @simulate="simulate" /> tilt-hint-text="倾斜手机查看光栅效果" :shimmer-mid-opacity="0.16" @simulate="simulate" />
</view> </view>
<image v-else class="card-image" :src="coverUrl" mode="aspectFill"></image> <image v-else class="card-image" :src="coverUrl" mode="aspectFill"></image>
@ -274,9 +274,6 @@ const lenticularLayers = ref([]);
const { const {
layerTransforms, layerTransforms,
simulate, simulate,
gyroSourceLabel,
scheduleTiltStart,
stopTiltPreview,
} = useLenticularCraftTiltPreview(lenticularLayers); } = useLenticularCraftTiltPreview(lenticularLayers);
const isLenticularAsset = computed(() => { const isLenticularAsset = computed(() => {
@ -646,7 +643,6 @@ const loadData = async () => {
? buildLenticularLayersTwo(bgUrl, subjectUrl) ? buildLenticularLayersTwo(bgUrl, subjectUrl)
: buildLenticularLayers(subjectUrl) : buildLenticularLayers(subjectUrl)
isLenticularDetail.value = true isLenticularDetail.value = true
scheduleTiltStart()
} }
}).catch(err => { }).catch(err => {
console.error('加载封面图片失败:', err); console.error('加载封面图片失败:', err);
@ -750,7 +746,6 @@ const loadCraftConfirm = () => {
if (raw) { if (raw) {
const p = typeof raw === 'string' ? JSON.parse(raw) : raw; const p = typeof raw === 'string' ? JSON.parse(raw) : raw;
lenticularLayers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || ''); lenticularLayers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '');
scheduleTiltStart();
} }
} }
} catch (e) { } catch (e) {
@ -871,12 +866,6 @@ onShow(() => {
}); });
onHide(() => { onHide(() => {
if (craftConfirmMode.value && isCraftLenticular.value) {
stopTiltPreview();
}
if (isLenticularDetail.value) {
stopTiltPreview()
}
stopLikePolling(); stopLikePolling();
}); });
@ -888,7 +877,6 @@ onUnload(() => {
onUnmounted(() => { onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer); if (countdownTimer) clearInterval(countdownTimer);
stopTiltPreview();
stopLikePolling(); stopLikePolling();
uni.$off('assetLikeChanged', handleAssetLikeChangedExternal); uni.$off('assetLikeChanged', handleAssetLikeChangedExternal);
}); });

View File

@ -27,7 +27,7 @@
<view class="craft-lenticular-slot"> <view class="craft-lenticular-slot">
<LenticularCard v-if="lenticularLayers.length > 0" class="craft-lenticular-card" <LenticularCard v-if="lenticularLayers.length > 0" class="craft-lenticular-card"
:layers="lenticularLayers" :transforms="layerTransforms" :layers="lenticularLayers" :transforms="layerTransforms"
:gyro-source="gyroSourceLabel" :skip-built-in-touch="true" tilt-hint-text="晃动查看" gyro-source="simulation" tilt-hint-text="晃动查看"
:shimmer-mid-opacity="0.16" @simulate="simulate" /> :shimmer-mid-opacity="0.16" @simulate="simulate" />
</view> </view>
</view> </view>
@ -107,9 +107,6 @@ const lenticularLayers = ref([])
const { const {
layerTransforms, layerTransforms,
simulate, simulate,
gyroSourceLabel,
scheduleTiltStart,
stopTiltPreview,
} = useLenticularCraftTiltPreview(lenticularLayers); } = useLenticularCraftTiltPreview(lenticularLayers);
// NftCard // NftCard
@ -190,7 +187,6 @@ onMounted(async () => {
if ((asset.is_lenticular || data.is_lenticular) && bgImageUrl && imageUrl) { if ((asset.is_lenticular || data.is_lenticular) && bgImageUrl && imageUrl) {
isLenticular.value = true isLenticular.value = true
lenticularLayers.value = buildLenticularLayersTwo(bgImageUrl, imageUrl) lenticularLayers.value = buildLenticularLayersTwo(bgImageUrl, imageUrl)
scheduleTiltStart()
} }
} else { } else {
// API 使 // API 使
@ -239,7 +235,6 @@ function applyLocalNftData(data) {
if (data.is_lenticular && data.image && data.bg_image) { if (data.is_lenticular && data.image && data.bg_image) {
isLenticular.value = true isLenticular.value = true
lenticularLayers.value = buildLenticularLayersTwo(data.bg_image, data.image) lenticularLayers.value = buildLenticularLayersTwo(data.bg_image, data.image)
scheduleTiltStart()
} }
} }
@ -272,7 +267,6 @@ const handleViewDetails = () => {
}; };
onUnload(() => { onUnload(() => {
stopTiltPreview()
try { try {
uni.removeStorageSync('temp_nft_data'); uni.removeStorageSync('temp_nft_data');
} catch (e) { } catch (e) {

View File

@ -33,8 +33,7 @@
class="lenticular-preview" class="lenticular-preview"
:layers="lenticularLayers" :layers="lenticularLayers"
:transforms="layerTransforms" :transforms="layerTransforms"
:gyro-source="gyroSourceLabel" gyro-source="simulation"
:skip-built-in-touch="true"
tilt-hint-text="晃动查看" tilt-hint-text="晃动查看"
:shimmer-mid-opacity="0.16" :shimmer-mid-opacity="0.16"
@simulate="simulate" @simulate="simulate"
@ -91,8 +90,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { onShow, onHide } from '@dcloudio/uni-app';
import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js'; import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js';
import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js'; import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js';
import LenticularCard from '@/components/lenticular/LenticularCard.vue'; import LenticularCard from '@/components/lenticular/LenticularCard.vue';
@ -125,9 +123,6 @@ const lenticularLayers = ref([]);
const { const {
layerTransforms, layerTransforms,
simulate, simulate,
gyroSourceLabel,
scheduleTiltStart,
stopTiltPreview,
} = useLenticularCraftTiltPreview(lenticularLayers); } = useLenticularCraftTiltPreview(lenticularLayers);
const isCraftDetailFlow = computed(() => isDetailAfterSelect(craftFormData.value)); const isCraftDetailFlow = computed(() => isDetailAfterSelect(craftFormData.value));
@ -802,7 +797,6 @@ const initLenticularPreview = () => {
const p = typeof raw === 'string' ? JSON.parse(raw) : raw; const p = typeof raw === 'string' ? JSON.parse(raw) : raw;
lenticularLayers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || ''); lenticularLayers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '');
selectedIndex.value = 0; selectedIndex.value = 0;
scheduleTiltStart();
} catch (e) { } catch (e) {
console.error('[GenerationResult] lenticular init', e); console.error('[GenerationResult] lenticular init', e);
} }
@ -849,19 +843,6 @@ onMounted(() => {
} }
}); });
onShow(() => {
if (isLenticularDisplay.value && lenticularLayers.value.length) {
scheduleTiltStart();
}
});
onHide(() => {
stopTiltPreview();
});
onUnmounted(() => {
stopTiltPreview();
});
</script> </script>
<style scoped> <style scoped>