/** * 光栅叠化引擎(自 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, } 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 = [] 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) { this.layers = [...layers] this.layerOffsetXSmoothed.clear() this.parallaxBlendTSmoothed = 0 for (const layer of this.layers) { this.renderState.layerOffsets.set(layer.id, { x: 0, y: 0 }) this.renderState.layerOpacities.set(layer.id, layer.opacity) } } 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(), }) } 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 blendBase = 0.04 + smooth * 0.13 + stability * 0.15 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 floor = isAnchor && N > 1 ? 0.18 : 0 const finalOpacity = clamp(Math.max(floor, 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 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) { 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 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 } } }