257 lines
7.5 KiB
JavaScript
257 lines
7.5 KiB
JavaScript
/**
|
||
* 光栅叠化引擎(自 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 }
|
||
}
|
||
}
|