topfans/frontend/utils/lenticular-engine.js

257 lines
7.5 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,
}
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 }
}
}