topfans/frontend/utils/lenticular-engine.js
2026-05-16 02:42:32 +08:00

312 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 光栅叠化引擎(自 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 }
}
}