/** * 光栅叠化引擎(自 Raster-Card-L 移植为纯 JS,无 DOM 依赖) */ export const PREV_LAYER_GHOST_MIN = 0.065 const NON_DOMINANT_RESIDUAL_MIN = 0.055 const PARALLAX_UNIFY_GAP_LO = 0.065 const PARALLAX_UNIFY_GAP_HI = 0.2 const OFFSET_X_EMA_BASE = 0.16 const OFFSET_X_EMA_STAB_SCALE = 0.26 const PARALLAX_BLEND_T_EMA_BASE = 0.14 const PARALLAX_BLEND_T_EMA_STAB_SCALE = 0.24 export const DEFAULT_PHYSICS = { tiltSensitivity: 72, transitionSmoothness: 66, parallaxDepth: 0, gyroSimEnabled: true, backgroundAnglePercent: 30, angleStability: 88, 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) { return a + (b - a) * t } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)) } function smoothstep(edge0, edge1, x) { const t = clamp((x - edge0) / (edge1 - edge0), 0, 1) return t * t * (3 - 2 * t) } function softAttenuateNearZero(g, db) { if (db <= 1e-9) return g const a = Math.abs(g) if (a >= db) return g const k = smoothstep(0, db, a) return (g >= 0 ? 1 : -1) * a * k } export class LenticularEngine { constructor(physics) { this.layers = [] /** 与 useLenticularPreview 传入的 reactive 对象保持同一引用,便于页面侧调参即时生效 */ this.physics = physics this.smoothedSensor = { gamma: 0, beta: 0, timestamp: 0 } this.displayGamma = 0 this.layerOffsetXSmoothed = new Map() this.parallaxBlendTSmoothed = 0 this.renderState = { layerOffsets: new Map(), layerOpacities: new Map(), stripePhaseShift: 0, stripShares: [1 / 3, 1 / 3, 1 / 3], lenticularPitchPx: 14, prevLayerGhost: null, } } setLayers(layers) { const prevOffsetX = new Map(this.layerOffsetXSmoothed) const prevParallaxBlendT = this.parallaxBlendTSmoothed this.layers = [...layers] this.layerOffsetXSmoothed.clear() 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) } } } updatePhysics(config) { Object.assign(this.physics, config) } getPhysics() { return { ...this.physics } } feedSensor(raw) { const alpha = this.calcSmoothingAlpha() this.smoothedSensor = { gamma: lerp(this.smoothedSensor.gamma, raw.gamma, alpha), beta: 0, timestamp: raw.timestamp, } this.updateDisplayStable() return this.computeRenderState() } feedSimulatedTilt(normalizedX, _normalizedY) { return this.feedSensor({ gamma: clamp(normalizedX, -1, 1), beta: 0, timestamp: Date.now(), }) } /** * 将内部倾斜状态与 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 const gamma = this.displayGamma const N = this.layers.length const gPick = clamp(gamma * (0.44 + sensitivity * 0.52), -1, 1) const u = (gPick + 1) / 2 const stability = this.physics.angleStability / 100 const smooth = this.physics.transitionSmoothness / 100 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 = [] let cum = 0 for (let i = 0; i < N; i++) { centers.push(cum + shares[i] / 2) cum += shares[i] } const rawWeights = [] for (let i = 0; i < N; i++) { const half = shares[i] / 2 + blendBase const d = Math.abs(u - centers[i]) rawWeights.push(Math.max(0, 1 - d / half)) } const sumW = rawWeights.reduce((a, b) => a + b, 0) || 1 const smoothedW = [] for (let i = 0; i < N; i++) { let weight = rawWeights[i] / sumW weight = weight * weight * (3 - 2 * weight) smoothedW.push(weight) } let dominant = 0 let bestScore = -1 for (let i = 0; i < N; i++) { const layer = this.layers[i] const score = layer.opacity * smoothedW[i] if (score > bestScore) { bestScore = score dominant = i } } this.renderState.prevLayerGhost = null const parallaxScale = 0.42 const sortedW = [...smoothedW].sort((a, b) => b - a) const weightGap = N >= 2 ? sortedW[0] - sortedW[1] : 1 const parallaxLayerBlendTRaw = smoothstep(PARALLAX_UNIFY_GAP_LO, PARALLAX_UNIFY_GAP_HI, weightGap) const stabForEma = clamp(stability, 0, 1) const blendTEmaMix = PARALLAX_BLEND_T_EMA_BASE + stabForEma * PARALLAX_BLEND_T_EMA_STAB_SCALE this.parallaxBlendTSmoothed = lerp(this.parallaxBlendTSmoothed, parallaxLayerBlendTRaw, blendTEmaMix) const parallaxLayerBlendT = this.parallaxBlendTSmoothed const swSum = smoothedW.reduce((a, b) => a + b, 0) || 1 let unifiedParallaxFactor = 0 for (let i = 0; i < N; i++) { unifiedParallaxFactor += (smoothedW[i] / swSum) * this.layers[i].parallaxFactor } const unifiedEffective = maxDisplacement * sensitivity * unifiedParallaxFactor * parallaxScale const unifiedOffsetX = gamma * unifiedEffective for (let i = 0; i < N; i++) { const layer = this.layers[i] const specificEffective = maxDisplacement * sensitivity * layer.parallaxFactor * parallaxScale const specificOffsetX = gamma * specificEffective const offsetXRaw = lerp(unifiedOffsetX, specificOffsetX, parallaxLayerBlendT) const offsetEmaMix = OFFSET_X_EMA_BASE + stabForEma * OFFSET_X_EMA_STAB_SCALE const prevX = this.layerOffsetXSmoothed.get(layer.id) const offsetX = prevX === undefined ? offsetXRaw : lerp(prevX, offsetXRaw, offsetEmaMix) this.layerOffsetXSmoothed.set(layer.id, offsetX) this.renderState.layerOffsets.set(layer.id, { x: offsetX, y: 0 }) const weight = smoothedW[i] const isAnchor = i === 0 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 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) this.renderState.prevLayerGhost = { layerId: prev.id, alpha: minGhost } } 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 = 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)) } } this.renderState.stripShares = [...shares] this.renderState.stripePhaseShift = gamma * (0.38 + 0.52 * sensitivity) this.renderState.lenticularPitchPx = clamp( Number.isFinite(this.physics.lenticularPitchPx) ? this.physics.lenticularPitchPx : 14, 8, 64 ) return this.renderState } computeStripShares(N) { if (N <= 0) return [] if (N === 1) return [1] if (N !== 3) { const w = 1 / N return Array.from({ length: N }, () => w) } const bg = clamp(this.physics.backgroundAnglePercent / 100, 0.1, 0.55) const rest = (1 - bg) / 2 return [bg, rest, rest] } updateDisplayStable() { const stab = clamp(this.physics.angleStability / 100, 0, 1) const k = 0.006 + (1 - stab) * 0.095 let g = lerp(this.displayGamma, this.smoothedSensor.gamma, k) const dead = this.physics.sensorDeadzoneStrength != null ? Number(this.physics.sensorDeadzoneStrength) : 1 if (!Number.isFinite(dead) || dead <= 1e-6) { this.displayGamma = clamp(g, -1, 1) return } const db = (0.016 + (1 - stab) * 0.034) * dead this.displayGamma = softAttenuateNearZero(g, db) } calcSmoothingAlpha() { const s = this.physics.transitionSmoothness / 100 return 1 - s * 0.9 } getRenderState() { return this.renderState } getSmoothedSensor() { return { ...this.smoothedSensor } } }