fix修复光栅卡陀螺仪问题

This commit is contained in:
liulong 2026-05-15 10:08:39 +08:00
parent 3a59aec48f
commit a808c808d8
7 changed files with 816 additions and 52 deletions

View File

@ -56,6 +56,8 @@ const props = defineProps({
approximatePreview: { type: Boolean, default: true },
skipBuiltInTouch: { type: Boolean, default: false },
tiltHintText: { type: String, default: '倾斜手机预览' },
/** 条纹高光中线不透明度 0~1略提可增强光栅「栅感」 */
shimmerMidOpacity: { type: Number, default: 0.1 },
})
const emit = defineEmits(['simulate'])
@ -119,8 +121,9 @@ const cardRotateStyle = computed(() => {
const shimmerStyle = computed(() => {
const x = tiltVisualX.value
const angle = 135 + (x / 120) * 60
const a = Math.max(0, Math.min(0.35, Number(props.shimmerMidOpacity) || 0.1))
return {
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,0.10) 50%, transparent 70%)`,
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,${a}) 50%, transparent 70%)`,
}
})

View File

@ -73,27 +73,42 @@ export function useLenticularPreview(layersRef) {
return Array.isArray(v) ? v : []
}
function applyLayerTransformsFromRenderState(ls, renderState) {
const next = {}
for (const layer of ls) {
const offset = renderState.layerOffsets.get(layer.id)
const opacity = renderState.layerOpacities.get(layer.id)
next[layer.id] = {
x: offset != null ? offset.x : 0,
y: offset != null ? offset.y : 0,
opacity: opacity != null ? opacity : layer.opacity,
}
}
layerTransforms.value = next
stripeRender.value = {
phaseShift: renderState.stripePhaseShift,
shares: [...renderState.stripShares],
pitchPx: renderState.lenticularPitchPx,
prevLayerGhost: renderState.prevLayerGhost,
}
}
/**
* 将引擎内部 displayGamma 与平滑状态对齐到当前 sensorData.gamma并立即刷新 layerTransforms
* @param {{ resetLayerSmoothing?: boolean }} [options] resetLayerSmoothing清空视差 EMA首帧叠化更干净
*/
function snapSimulatedTilt(options = {}) {
const g = clamp(sensorData.value.gamma, -1, 1)
const ls = getLayersArray()
const renderState = engine.snapSimulatedTilt(g, options)
applyLayerTransformsFromRenderState(ls, renderState)
}
function tick() {
try {
const ls = getLayersArray()
const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
const next = {}
for (const layer of ls) {
const offset = renderState.layerOffsets.get(layer.id)
const opacity = renderState.layerOpacities.get(layer.id)
next[layer.id] = {
x: offset != null ? offset.x : 0,
y: offset != null ? offset.y : 0,
opacity: opacity != null ? opacity : layer.opacity,
}
}
layerTransforms.value = next
stripeRender.value = {
phaseShift: renderState.stripePhaseShift,
shares: [...renderState.stripShares],
pitchPx: renderState.lenticularPitchPx,
prevLayerGhost: renderState.prevLayerGhost,
}
applyLayerTransformsFromRenderState(ls, renderState)
} catch (e) {
console.error('[useLenticularPreview] tick failed', e)
}
@ -148,6 +163,7 @@ export function useLenticularPreview(layersRef) {
gyro,
simulate,
relax,
snapSimulatedTilt,
engine,
startRenderLoop,
stopRenderLoop,

View File

@ -560,7 +560,8 @@ export function useLenticularStudioTilt(opts) {
*/
const useSync =
typeof mod.getGyroValueSync === 'function' && typeof mod.getGyroValue !== 'function'
const intervalMs = useSync ? 20 : 28
/* 略快于插件 game 传感器节奏即可;过慢会显得跟手迟滞 */
const intervalMs = useSync ? 22 : 26
const pollOnce = () => {
if (myGen !== tiltGen || !gyroModule) return
try {

View File

@ -6,8 +6,8 @@
{ "path": "pages/laser-card-studio/laser-card-studio", "style": { "navigationStyle": "custom", "backgroundColor": "#0a0a0a" } }
App 端说明
- 顶栏/底栏使用 statusBarHeightsafeAreaInsets.bottom避免仅靠 CSS 变量导致刘海屏错位
- 预览与导出圆角 clip 可选内置镭射底图 cover 人物 cover HSL/幻彩/噪点等底图可选置于人物后方预览用 requestAnimationFrame
- iOS WKWebView / Android System WebViewCanvas 合成使globalCompositeOperation本页已移除 DOM 叠层预览
- 预览优先 WebGL Pass 全息着色FBM + 视角色相 + 色散闪点失败则 2D Canvas + requestAnimationFrame导出仍为 2D 离屏 Canvas
- iOS WKWebView / Android System WebView2D 分支globalCompositeOperation本页已移除 DOM 叠层预览
- 顶栏与预览区 position: sticky便于长屏下仍可操作顶栏
- 内置强全息默认参数无调校 UI保存写入相册或铸爱下单不再弹窗询问保存调校参数
-->
@ -24,19 +24,32 @@
<view class="preview-sticky" :style="{ top: stickyHeaderTotalPx + 'px' }">
<view class="preview-wrap">
<block v-if="previewSrc">
<canvas
canvas-id="laserPreviewCanvas"
class="preview-canvas"
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
:width="exportCssW"
:height="exportCssH"
:disable-scroll="true"
/>
<view class="preview-canvas-stack" :style="{ width: exportCssW + 'px', height: exportCssH + 'px' }">
<canvas
v-show="!webglPreviewReady"
canvas-id="laserPreviewCanvas"
class="preview-canvas preview-canvas--2d"
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
:width="exportCssW"
:height="exportCssH"
:disable-scroll="true"
/>
<canvas
v-show="webglPreviewReady"
id="laserPreviewWebgl"
type="webgl"
class="preview-canvas preview-canvas--webgl"
:style="{ width: exportCssW + 'px', height: exportCssH + 'px' }"
:width="exportCssW"
:height="exportCssH"
:disable-scroll="true"
/>
</view>
<view class="preview-fab" @tap.stop="onPreviewTap">
<text class="preview-fab-text">换图</text>
</view>
<view class="preview-replace-hint">
<text class="preview-replace-text">全息镭射满铺预览 · 保存导出高清图</text>
<text class="preview-replace-text">WebGL 全息预览 · 保存仍为高清 2D 导出</text>
</view>
</block>
<view v-else class="preview-placeholder">
@ -88,6 +101,11 @@ import { segmentPortraitToLocal } from '@/utils/laser-card/segmentationCloud.js'
import { humanizeIfBareOssUploadErr } from '@/utils/laser-card/aliyunPortraitUni.js'
import { submitCastloveAfterLaserExport } from '@/utils/castloveAfterLaserMint.js'
import { CASTLOVE_LASER_ENTRY_KEY } from '@/utils/castloveMintForm.js'
import {
createLaserPreviewWebgl,
loadTextureImage,
BEAM_STYLE_HUE
} from '@/utils/laser-card/laserPreviewWebgl.js'
const PARAM_PRESET_STORAGE_KEY = 'laser_card_param_presets_v1'
@ -237,7 +255,14 @@ export default {
/** 常用光束角度快捷(度) */
angleQuickDegrees: [0, 15, 30, 45, 60, 90, 120, 135, 150, 180, 210, 225, 240, 270, 300, 315, 330],
/** 铸爱单图入口create 写入,保存后走下单 */
castloveEntry: null
castloveEntry: null,
/** WebGL 预览:成功前仍走 2D 回退 */
webglPreviewReady: false,
_webglR: null,
_webglCanvasNode: null,
_webglBooting: false,
/** 递增以作废进行中的 WebGL 异步启动 */
laserWebglSession: 0
}
},
computed: {
@ -763,6 +788,7 @@ export default {
previewSrc(src) {
this._previewLayout = null
this._stopPreviewAnimation()
this._destroyWebglPreview()
if (src) {
this.$nextTick(() => {
this._refreshPreviewLayoutFromSrc()
@ -811,6 +837,7 @@ export default {
},
onUnload() {
this._stopPreviewAnimation()
this._destroyWebglPreview()
},
methods: {
_computeCoverLayout(iw, ih, cw, ch) {
@ -896,6 +923,7 @@ export default {
src: p,
success: (img) => {
this._backdropLayout = this._computeCoverLayout(img.width, img.height, cw, ch)
this._reloadWebglBackdropTextureOnly()
},
fail: (err) => {
console.warn('[LaserCardStudio] backdrop getImageInfo', err)
@ -999,6 +1027,190 @@ export default {
return uni.createCanvasContext(canvasId, this)
// #endif
},
_destroyWebglPreview() {
this.laserWebglSession++
this.webglPreviewReady = false
this._webglBooting = false
if (this._webglR) {
try {
this._webglR.destroy()
} catch (e) {
/* noop */
}
this._webglR = null
}
this._webglCanvasNode = null
},
/**
* 查询 type=webgl 画布 dpr 设物理像素编译着色器
* @returns {Promise<boolean>}
*/
_initWebglPreviewEngine() {
return new Promise((resolve) => {
if (this._webglR) {
try {
this._webglR.destroy()
} catch (e) {
/* noop */
}
this._webglR = null
}
try {
const q = uni.createSelectorQuery().in(this)
q.select('#laserPreviewWebgl')
.fields({ node: true, size: true })
.exec((res) => {
const row = res && res[0]
if (!row || !row.node) {
resolve(false)
return
}
const canvas = row.node
this._webglCanvasNode = canvas
let pr = 1
try {
const sys = uni.getSystemInfoSync()
pr = Math.min(2.5, Math.max(1, Number(sys.pixelRatio) || 1))
} catch (e1) {
pr = 1
}
const bw = Math.max(2, Math.floor(this.exportCssW * pr))
const bh = Math.max(2, Math.floor(this.exportCssH * pr))
try {
canvas.width = bw
canvas.height = bh
} catch (e2) {
resolve(false)
return
}
const gl =
canvas.getContext('webgl', {
alpha: true,
antialias: true,
preserveDrawingBuffer: false,
stencil: false,
depth: false
}) || canvas.getContext('experimental-webgl')
if (!gl) {
resolve(false)
return
}
try {
this._webglR = createLaserPreviewWebgl(gl, {
width: this.exportCssW,
height: this.exportCssH
})
resolve(true)
} catch (e3) {
console.warn('[LaserCardStudio] WebGL program', e3)
resolve(false)
}
})
} catch (e) {
resolve(false)
}
})
},
async _loadWebglTextures() {
if (!this._webglR || !this.previewSrc || !this._webglCanvasNode) {
return
}
const photoImg = await loadTextureImage(this.previewSrc, this._webglCanvasNode)
this._webglR.uploadPhoto(photoImg)
const bp = this._resolveLaserBackdropPath()
if (bp) {
try {
const bi = await loadTextureImage(bp, this._webglCanvasNode)
this._webglR.uploadBackdrop(bi)
} catch (e) {
this._webglR.uploadBackdrop(null)
}
} else {
this._webglR.uploadBackdrop(null)
}
},
async _reloadWebglBackdropTextureOnly() {
if (!this._webglR || !this._webglCanvasNode) {
return
}
const sess = this.laserWebglSession
const bp = this._resolveLaserBackdropPath()
if (!bp) {
this._webglR.uploadBackdrop(null)
return
}
try {
const bi = await loadTextureImage(bp, this._webglCanvasNode)
if (sess !== this.laserWebglSession) {
return
}
this._webglR.uploadBackdrop(bi)
} catch (e) {
if (sess !== this.laserWebglSession) {
return
}
this._webglR.uploadBackdrop(null)
}
},
async _bootWebglPreview() {
if (this._webglBooting || !this.previewSrc || !this._previewLayout) {
return
}
this._webglBooting = true
const sess = this.laserWebglSession
try {
const ok = await this._initWebglPreviewEngine()
if (sess !== this.laserWebglSession) {
return
}
if (!ok) {
this.webglPreviewReady = false
return
}
await this._loadWebglTextures()
if (sess !== this.laserWebglSession) {
return
}
if (!this._webglR || !this._webglR.hasPhoto()) {
this.webglPreviewReady = false
return
}
this.webglPreviewReady = true
} catch (e) {
console.warn('[LaserCardStudio] WebGL boot', e)
this.webglPreviewReady = false
} finally {
this._webglBooting = false
}
},
_renderWebglPreviewFrame(t) {
if (!this.webglPreviewReady || !this._webglR || !this._previewLayout) {
return
}
const timeMs = typeof t === 'number' ? t : Date.now()
const vivid = Math.max(0.72, Math.min(1.35, (Number(this.previewVivid) || 100) / 100))
const strength = Math.max(0.42, Math.min(1.12, (Number(this.laserStrength) || 65) / 100))
const animSpeed = Math.max(0.35, Math.min(1.8, (Number(this.previewAnimSpeed) || 100) / 100))
const styleHue =
typeof BEAM_STYLE_HUE[this.beamEffectId] === 'number'
? BEAM_STYLE_HUE[this.beamEffectId]
: BEAM_STYLE_HUE.prism
const cw = this.exportCssW
const ch = this.exportCssH
const hasBd = !!(this._backdropLayout && this._resolveLaserBackdropPath())
this._webglR.render(timeMs, {
photoRect: this._previewLayout,
backRect: this._backdropLayout,
hasBackdrop: hasBd,
cornerRadiusPx: Math.min(32, cw * 0.065, ch * 0.048),
vivid,
strength,
styleHue,
animSpeed,
canvasW: cw,
canvasH: ch
})
},
_schedulePreviewFrame(cb) {
if (typeof requestAnimationFrame === 'function') {
return requestAnimationFrame(cb)
@ -1054,6 +1266,7 @@ export default {
setTimeout(() => {
this._refreshBackdropLayout()
this._startPreviewAnimation()
this._bootWebglPreview()
}, 48)
})
},
@ -1073,7 +1286,11 @@ export default {
this._previewRafId = null
return
}
this._renderLaserPreviewFrame()
if (this.webglPreviewReady && this._webglR) {
this._renderWebglPreviewFrame(Date.now())
} else {
this._renderLaserPreviewFrame()
}
this._previewRafId = this._schedulePreviewFrame(loop)
}
this._previewRafId = this._schedulePreviewFrame(loop)
@ -2458,6 +2675,24 @@ export default {
justify-content: center;
}
.preview-canvas-stack {
position: relative;
flex-shrink: 0;
margin: 0 auto;
}
.preview-canvas--2d,
.preview-canvas--webgl {
display: block;
}
.preview-canvas--webgl {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
}
.preview-canvas {
display: block;
flex-shrink: 0;

View File

@ -27,6 +27,7 @@
:gyro-source="gyroSourceLabel"
:skip-built-in-touch="true"
:tilt-hint-text="''"
:shimmer-mid-opacity="0.16"
@simulate="simulate"
/>
</view>
@ -84,14 +85,21 @@ import {
const layers = ref([])
const gyroSourceLabel = ref('simulation')
const { physics, layerTransforms, simulate } = useLenticularPreview(layers)
const { physics, layerTransforms, simulate, relax, snapSimulatedTilt } = useLenticularPreview(layers)
/** 相对进入时基准的倾角标量(度),每满 15° 一档;左右倾斜等价 */
const STUDIO_DISCRETE_STEP_DEG = 15
/** 档位施密特迟滞(度),减轻在档位边界来回跳 */
const DISCRETE_STEP_HYST_DEG = 4
/** 相对进入时基准的倾角标量(度),每满一档换一层;越大需掰得越狠才换图,体感更慢 */
const STUDIO_DISCRETE_STEP_DEG = 13
/** 档位施密特迟滞(度),减轻在档位边界来回跳;换向经过水平附近时略加大更稳 */
const DISCRETE_STEP_HYST_DEG = 5
/** 进入离散逻辑前对倾角幅度做 EMA上升快、下降慢减轻换向时「先掉档再升档」的抖动 */
const STUDIO_TILT_MAG_EMA = 0.17
const STUDIO_TILT_MAG_EMA_DECAY_RATIO = 0.42
/** 进入页后延迟开启倾斜监听ms避免首帧/基线采集/大图解码时画面跟着抖 */
const STUDIO_TILT_START_DELAY_MS = 560
const discreteStableStep = ref(0)
/** 与离散档位同步平滑的倾角幅度(度),换图时缓变避免观感断裂 */
let studioTiltMagEma = 0
/**
* 将相对基准的倾角标量非负映射为 LenticularEngine gamma
@ -100,10 +108,14 @@ const discreteStableStep = ref(0)
function simulateFromSignedDegrees(tiltMagDeg) {
const ls = layers.value || []
const n = Math.max(1, ls.length)
const absDeg = Math.abs(Number(tiltMagDeg) || 0)
const raw = Math.abs(Number(tiltMagDeg) || 0)
const emaAlpha = raw >= studioTiltMagEma ? STUDIO_TILT_MAG_EMA : STUDIO_TILT_MAG_EMA * STUDIO_TILT_MAG_EMA_DECAY_RATIO
studioTiltMagEma += (raw - studioTiltMagEma) * emaAlpha
const absDeg = studioTiltMagEma
const STEP = STUDIO_DISCRETE_STEP_DEG
const MARGIN = DISCRETE_STEP_HYST_DEG
let s = discreteStableStep.value
const stepBefore = discreteStableStep.value
let s = stepBefore
while (absDeg >= (s + 1) * STEP + MARGIN) s++
while (s > 0 && absDeg <= s * STEP - MARGIN) s--
discreteStableStep.value = s
@ -114,6 +126,10 @@ function simulateFromSignedDegrees(tiltMagDeg) {
const gPick = Math.max(-1, Math.min(1, 2 * u - 1))
const gamma = Math.max(-1, Math.min(1, gPick / mul))
simulate(gamma, 0)
/* 档位切换瞬间把引擎 displayGamma 对齐到目标,避免多帧渐近 + 宽叠化带叠成「糊、抖」 */
if (s !== stepBefore) {
snapSimulatedTilt({ resetLayerSmoothing: false })
}
}
const { start: startTilt, stop: stopTilt, recalibrate: recalibrateTilt } = useLenticularStudioTilt({
@ -123,9 +139,20 @@ const { start: startTilt, stop: stopTilt, recalibrate: recalibrateTilt } = useLe
useStudioAccelDirect: true,
onTiltDriverFallback: () => {
discreteStableStep.value = 0
studioTiltMagEma = 0
},
})
/** 首屏与换素材后:档位与 EMA 归零,并写入与 0° 档位一致的 gamma传感器未开时保持静止 */
function lockStudioPreviewStill() {
discreteStableStep.value = 0
studioTiltMagEma = 0
simulateFromSignedDegrees(0)
snapSimulatedTilt({ resetLayerSmoothing: true })
}
let entryTiltTimer = null
function goBack() {
uni.navigateBack({
delta: 1,
@ -143,6 +170,9 @@ function onMore() {
try {
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
discreteStableStep.value = 0
studioTiltMagEma = 0
nextTick(() => relax(0.86))
uni.showToast({ title: '已恢复', icon: 'none' })
} catch (e) {
console.error(e)
@ -175,6 +205,7 @@ function pickImage(layerId) {
const copy = [...layers.value]
copy[idx] = next
layers.value = copy
nextTick(() => relax(0.86))
},
fail: (err) => {
const msg = (err && err.errMsg) || ''
@ -193,11 +224,17 @@ function resetLayer(layerId) {
const copy = [...layers.value]
copy[idx] = { ...defLayer }
layers.value = copy
nextTick(() => relax(0.86))
}
function onRecalibrate() {
discreteStableStep.value = 0
studioTiltMagEma = 0
recalibrateTilt()
nextTick(() => {
simulateFromSignedDegrees(0)
snapSimulatedTilt({ resetLayerSmoothing: true })
})
uni.showToast({ title: '已重置水平基准', icon: 'none' })
}
@ -211,6 +248,18 @@ onMounted(() => {
}
const p = typeof raw === 'string' ? JSON.parse(raw) : raw
layers.value = buildLenticularLayersTwo(p.bgPath || '', p.subjectPath || '')
/* 光栅感:略抬残影/ghost + 叠化带宽;开启轻量视差,换图时另一张轮廓更易分辨 */
Object.assign(physics, {
angleStability: 40,
transitionSmoothness: 28,
tiltSensitivity: 96,
sensorDeadzoneStrength: 0,
parallaxDepth: 18,
lenticularAnchorFloor: 0.1,
lenticularNonDominantResidualMin: 0.092,
lenticularPrevLayerGhostMin: 0.098,
lenticularBlendBaseScale: 0.95,
})
} catch (e) {
console.error('[lenticular-studio] load payload', e)
uni.showToast({ title: '数据无效', icon: 'none' })
@ -218,16 +267,23 @@ onMounted(() => {
return
}
nextTick(() => {
/* 离散换图:略降过渡混合,档位切换更利落 */
physics.angleStability = 6
physics.transitionSmoothness = 12
physics.tiltSensitivity = 96
physics.sensorDeadzoneStrength = 0
startTilt()
lockStudioPreviewStill()
if (entryTiltTimer != null) {
clearTimeout(entryTiltTimer)
entryTiltTimer = null
}
entryTiltTimer = setTimeout(() => {
entryTiltTimer = null
startTilt()
}, STUDIO_TILT_START_DELAY_MS)
})
})
onUnmounted(() => {
if (entryTiltTimer != null) {
clearTimeout(entryTiltTimer)
entryTiltTimer = null
}
stopTilt()
})
</script>
@ -334,6 +390,8 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
contain: layout paint;
transform: translateZ(0);
}
.canvas-bg {

View File

@ -0,0 +1,396 @@
/**
* 镭射卡预览专用 WebGL1 渲染器全息光栅风格单 Pass
* 预览与导出分离导出仍走 2D Canvas本模块仅用于页面内实时预览
*
* 依赖WebGL1 + OES_texture_float_linear 非必须仅用 RGBA8 纹理
*/
const VERT_SRC = `
attribute vec2 a_uv;
varying vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = vec4(a_uv * 2.0 - 1.0, 0.0, 1.0);
}
`
const FRAG_SRC = `
precision highp float;
varying vec2 v_uv;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec4 u_photoRect;
uniform vec4 u_backRect;
uniform float u_hasBackdrop;
uniform float u_corner;
uniform vec3 u_view;
uniform float u_vivid;
uniform float u_strength;
uniform float u_styleHue;
uniform sampler2D u_photo;
uniform sampler2D u_back;
const vec3 LUMA = vec3(0.299, 0.587, 0.114);
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float n2(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.52;
mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
for (int i = 0; i < 6; i++) {
v += a * n2(p);
p = m * p;
a *= 0.5;
}
return v;
}
float roundedBox(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}
vec3 hsl2rgb(float h, float s, float l) {
float c = (1.0 - abs(2.0 * l - 1.0)) * s;
float x = c * (1.0 - abs(mod(h / 60.0, 2.0) - 1.0));
float m = l - 0.5 * c;
vec3 rgb;
if (h < 60.0) rgb = vec3(c, x, 0.0);
else if (h < 120.0) rgb = vec3(x, c, 0.0);
else if (h < 180.0) rgb = vec3(0.0, c, x);
else if (h < 240.0) rgb = vec3(0.0, x, c);
else if (h < 300.0) rgb = vec3(x, 0.0, c);
else rgb = vec3(c, 0.0, x);
return rgb + m;
}
vec4 samplePhoto(vec2 uv) {
vec2 pRaw = (uv - u_photoRect.xy) / u_photoRect.zw;
vec2 p = clamp(pRaw, 0.0, 1.0);
return texture2D(u_photo, p);
}
float photoInside(vec2 uv) {
vec2 pRaw = (uv - u_photoRect.xy) / u_photoRect.zw;
return step(0.0, pRaw.x) * step(pRaw.x, 1.0) * step(0.0, pRaw.y) * step(pRaw.y, 1.0);
}
vec3 sampleBackdrop(vec2 uv) {
vec2 p = (uv - u_backRect.xy) / u_backRect.zw;
if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0) return vec3(0.04, 0.03, 0.06);
return texture2D(u_back, p).rgb;
}
void main() {
vec2 uv = v_uv;
vec2 pN = (uv - 0.5) * vec2(u_resolution.x / u_resolution.y, 1.0);
float rb = roundedBox(pN, vec2(0.5 * u_resolution.x / u_resolution.y, 0.5), u_corner);
if (rb > 0.002) discard;
vec3 baseBg = vec3(0.06, 0.045, 0.09);
if (u_hasBackdrop > 0.5) {
baseBg = sampleBackdrop(uv);
}
vec4 pc = samplePhoto(uv);
float photoMask = photoInside(uv);
vec3 photoRgb = pc.rgb;
float eps = 1.2 / u_resolution.x;
vec4 px1 = samplePhoto(uv + vec2(eps, 0.0));
vec4 px2 = samplePhoto(uv + vec2(0.0, eps));
float l0 = dot(photoRgb, LUMA);
float l1 = dot(px1.rgb, LUMA);
float l2 = dot(px2.rgb, LUMA);
float edge = clamp(abs(l1 - l0) + abs(l2 - l0), 0.0, 1.0) * 3.5;
vec2 pF = uv * vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0) * 3.4;
float f = fbm(pF + u_view.xy * 0.15);
float f2 = fbm(pF * 1.7 + vec2(13.1, 9.7) + u_view.z * 0.08);
vec2 g = vec2(fbm(pF + vec2(1.7, 0.0)) - fbm(pF - vec2(1.7, 0.0)), fbm(pF + vec2(0.0, 1.7)) - fbm(pF - vec2(0.0, 1.7)));
float ang = atan(g.y, g.x) + u_view.x * 1.15 + u_view.y * 0.9 + f * 6.28318 * 0.35;
float film = sin(dot(uv * u_resolution.xy * 0.02, vec2(1.0, 1.73)) * 3.14159 + u_time * 0.0012 + u_view.z);
float hue = fract(ang / 6.28318 + f2 * 0.22 + u_styleHue / 360.0 + u_time * 0.00008 + film * 0.08);
float sat = 0.72 + u_vivid * 0.28;
float lit = 0.48 + f * 0.22;
vec3 holo = hsl2rgb(hue * 360.0, sat, lit);
float carrier = smoothstep(0.15, 0.95, pow(f, 0.85)) * (0.35 + edge * 0.85);
carrier = clamp(carrier * u_strength * (0.55 + u_vivid * 0.45), 0.0, 1.35);
vec3 holoRgb = holo * (0.55 + 0.45 * sin(u_time * 0.001 + f * 10.0));
float sp = step(0.988, hash(floor(uv * u_resolution * 1.2) + floor(u_time * 0.02)));
vec3 sparkle = vec3(1.0) * sp * 0.55;
vec3 base = mix(baseBg, photoRgb, photoMask);
vec3 ir = holoRgb * carrier;
vec3 screenBlend = 1.0 - (1.0 - base) * (1.0 - clamp(ir * 1.1, 0.0, 1.0));
vec3 col = mix(base, screenBlend, clamp(carrier * 0.92, 0.0, 1.0));
col += sparkle * carrier;
float ca = 0.0018 * (0.5 + u_vivid);
vec2 o = vec2(ca * sin(u_view.z), ca * cos(u_view.z * 0.7));
vec3 cr = hsl2rgb(fract(hue + 0.02) * 360.0, sat * 0.9, lit * 1.05);
vec3 cb = hsl2rgb(fract(hue - 0.03) * 360.0, sat * 0.95, lit * 0.95);
col.r = mix(col.r, cr.r, 0.12 * carrier);
col.b = mix(col.b, cb.b, 0.12 * carrier);
float vign = 1.0 - pow(length(pN) / 0.72, 2.2) * 0.12;
col *= vign;
gl_FragColor = vec4(col, 1.0);
}
`
function compileShader(gl, type, src) {
const sh = gl.createShader(type)
gl.shaderSource(sh, src)
gl.compileShader(sh)
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
const err = gl.getShaderInfoLog(sh)
gl.deleteShader(sh)
throw new Error(err || 'shader compile')
}
return sh
}
function createProgram(gl, vs, fs) {
const prog = gl.createProgram()
gl.attachShader(prog, compileShader(gl, gl.VERTEX_SHADER, vs))
gl.attachShader(prog, compileShader(gl, gl.FRAGMENT_SHADER, fs))
gl.linkProgram(prog)
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
const err = gl.getProgramInfoLog(prog)
gl.deleteProgram(prog)
throw new Error(err || 'program link')
}
return prog
}
function orthoTexImage(gl, tex, source) {
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.bindTexture(gl.TEXTURE_2D, null)
}
/**
* @param {WebGLRenderingContext} gl
* @param {{ width: number, height: number, canvas?: any }} opts
*/
export function createLaserPreviewWebgl(gl, opts) {
const w = Math.max(2, Math.floor(opts.width))
const h = Math.max(2, Math.floor(opts.height))
const program = createProgram(gl, VERT_SRC, FRAG_SRC)
const loc = {
a_uv: gl.getAttribLocation(program, 'a_uv'),
u_resolution: gl.getUniformLocation(program, 'u_resolution'),
u_time: gl.getUniformLocation(program, 'u_time'),
u_photoRect: gl.getUniformLocation(program, 'u_photoRect'),
u_backRect: gl.getUniformLocation(program, 'u_backRect'),
u_hasBackdrop: gl.getUniformLocation(program, 'u_hasBackdrop'),
u_corner: gl.getUniformLocation(program, 'u_corner'),
u_view: gl.getUniformLocation(program, 'u_view'),
u_vivid: gl.getUniformLocation(program, 'u_vivid'),
u_strength: gl.getUniformLocation(program, 'u_strength'),
u_styleHue: gl.getUniformLocation(program, 'u_styleHue'),
u_photo: gl.getUniformLocation(program, 'u_photo'),
u_back: gl.getUniformLocation(program, 'u_back')
}
const buf = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
const quad = new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1])
gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW)
const texPhoto = gl.createTexture()
const texBack = gl.createTexture()
let photoReady = false
let backReady = false
function bindUnit(unit, tex, loc) {
gl.activeTexture(gl.TEXTURE0 + unit)
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.uniform1i(loc, unit)
}
return {
gl,
w,
h,
program,
/** 释放 GPU 资源 */
destroy() {
gl.deleteProgram(program)
gl.deleteBuffer(buf)
gl.deleteTexture(texPhoto)
gl.deleteTexture(texBack)
},
/** @param {HTMLImageElement|ImageBitmap|HTMLCanvasElement} img */
uploadPhoto(img) {
if (!img || !img.width) return
orthoTexImage(gl, texPhoto, img)
photoReady = true
},
/** @param {HTMLImageElement|ImageBitmap|HTMLCanvasElement|null} img */
uploadBackdrop(img) {
if (!img || !img.width) {
backReady = false
return
}
orthoTexImage(gl, texBack, img)
backReady = true
},
hasPhoto() {
return photoReady
},
hasBackdrop() {
return backReady
},
/**
* @param {number} timeMs
* @param {object} u
* @param {{ dx: number, dy: number, dw: number, dh: number }} u.photoRect 像素 cover 矩形
* @param {object} [u.backRect]
* @param {boolean} [u.hasBackdrop]
* @param {number} [u.cornerRadiusPx]
* @param {number} [u.vivid] 0.75~1.35
* @param {number} [u.strength] 0.35~1.2
* @param {number} [u.styleHue] beam 风格色相偏置 0~360
*/
render(timeMs, u) {
if (!photoReady) return
const pr = u.photoRect
const cw = u.canvasW || w
const ch = u.canvasH || h
const nx = pr.dx / cw
const ny = pr.dy / ch
const nw = pr.dw / cw
const nh = pr.dh / ch
let bx = 0
let nyb = 0
let nbw = 1
let nbh = 1
let hasB = !!(u.hasBackdrop && backReady && u.backRect)
if (hasB) {
const br = u.backRect
bx = br.dx / cw
nyb = br.dy / ch
nbw = br.dw / cw
nbh = br.dh / ch
}
const rPx = typeof u.cornerRadiusPx === 'number' ? u.cornerRadiusPx : Math.min(32, cw * 0.065, ch * 0.048)
const cornerUv = rPx / Math.min(cw, ch)
const t = (timeMs || 0) * 0.001
const speed = typeof u.animSpeed === 'number' ? u.animSpeed : 1
const vx = Math.sin(t * 0.42 * speed) * 0.7 + Math.sin(t * 0.17 * speed) * 0.35
const vy = Math.cos(t * 0.31 * speed) * 0.55 + Math.cos(t * 0.21 * speed) * 0.28
const vz = t * 0.55 * speed
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)
gl.disable(gl.DEPTH_TEST)
gl.disable(gl.STENCIL_TEST)
gl.clearColor(0.04, 0.03, 0.06, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(program)
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
gl.enableVertexAttribArray(loc.a_uv)
gl.vertexAttribPointer(loc.a_uv, 2, gl.FLOAT, false, 0, 0)
gl.uniform2f(loc.u_resolution, cw, ch)
gl.uniform1f(loc.u_time, timeMs || 0)
gl.uniform4f(loc.u_photoRect, nx, ny, nw, nh)
gl.uniform4f(loc.u_backRect, bx, nyb, nbw, nbh)
gl.uniform1f(loc.u_hasBackdrop, hasB ? 1 : 0)
gl.uniform1f(loc.u_corner, cornerUv)
gl.uniform3f(loc.u_view, vx, vy, vz)
gl.uniform1f(loc.u_vivid, typeof u.vivid === 'number' ? u.vivid : 1)
gl.uniform1f(loc.u_strength, typeof u.strength === 'number' ? u.strength : 0.82)
gl.uniform1f(loc.u_styleHue, typeof u.styleHue === 'number' ? u.styleHue : 0)
bindUnit(0, texPhoto, loc.u_photo)
bindUnit(1, texBack, loc.u_back)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindTexture(gl.TEXTURE_2D, null)
}
}
}
/**
* 从本地路径或 URL 加载图片App/H5 Image小程序等用 canvas.createImage
* @param {string} src
* @param {any} [canvasForCreateImage] type=webgl canvas node
*/
export function loadTextureImage(src, canvasForCreateImage) {
return new Promise((resolve, reject) => {
if (!src) {
reject(new Error('empty src'))
return
}
const c = canvasForCreateImage
if (c && typeof c.createImage === 'function') {
try {
const img = c.createImage()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('createImage load'))
img.src = src
return
} catch (e2) {
/* fall through */
}
}
if (typeof Image !== 'undefined') {
try {
const img = new Image()
try {
img.crossOrigin = 'anonymous'
} catch (e0) {
/* noop */
}
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('image load'))
img.src = src
return
} catch (e) {
reject(e)
return
}
}
reject(new Error('no image loader'))
})
}
/** 各 beamEffect 对应色相偏置(度),与 2D 视觉大致同系 */
export const BEAM_STYLE_HUE = {
white: 0,
prism: 18,
aurora: 42,
sunset: 6,
neon: 72
}

View File

@ -22,6 +22,18 @@ export const DEFAULT_PHYSICS = {
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) {
@ -48,7 +60,8 @@ function softAttenuateNearZero(g, db) {
export class LenticularEngine {
constructor(physics) {
this.layers = []
this.physics = { ...physics }
/** 与 useLenticularPreview 传入的 reactive 对象保持同一引用,便于页面侧调参即时生效 */
this.physics = physics
this.smoothedSensor = { gamma: 0, beta: 0, timestamp: 0 }
this.displayGamma = 0
this.layerOffsetXSmoothed = new Map()
@ -64,12 +77,18 @@ export class LenticularEngine {
}
setLayers(layers) {
const prevOffsetX = new Map(this.layerOffsetXSmoothed)
const prevParallaxBlendT = this.parallaxBlendTSmoothed
this.layers = [...layers]
this.layerOffsetXSmoothed.clear()
this.parallaxBlendTSmoothed = 0
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)
}
}
}
@ -100,6 +119,23 @@ export class LenticularEngine {
})
}
/**
* 将内部倾斜状态与 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
@ -111,7 +147,12 @@ export class LenticularEngine {
const stability = this.physics.angleStability / 100
const smooth = this.physics.transitionSmoothness / 100
const blendBase = 0.04 + smooth * 0.13 + stability * 0.15
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 = []
@ -180,14 +221,23 @@ export class LenticularEngine {
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)
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 minGhost = PREV_LAYER_GHOST_MIN * prev.opacity
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)
@ -195,10 +245,15 @@ export class LenticularEngine {
}
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 = NON_DOMINANT_RESIDUAL_MIN * layer.opacity
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))
}