diff --git a/frontend/components/lenticular/LenticularCard.vue b/frontend/components/lenticular/LenticularCard.vue index 9ee5c85..83e1fa6 100644 --- a/frontend/components/lenticular/LenticularCard.vue +++ b/frontend/components/lenticular/LenticularCard.vue @@ -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%)`, } }) diff --git a/frontend/composables/useLenticularPreview.js b/frontend/composables/useLenticularPreview.js index 69375a8..0bb8eb5 100644 --- a/frontend/composables/useLenticularPreview.js +++ b/frontend/composables/useLenticularPreview.js @@ -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, diff --git a/frontend/composables/useLenticularStudioTilt.js b/frontend/composables/useLenticularStudioTilt.js index 2a0a636..5083523 100644 --- a/frontend/composables/useLenticularStudioTilt.js +++ b/frontend/composables/useLenticularStudioTilt.js @@ -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 { diff --git a/frontend/pages/castlove/laser-card-studio.vue b/frontend/pages/castlove/laser-card-studio.vue index 17547ad..f1980d4 100644 --- a/frontend/pages/castlove/laser-card-studio.vue +++ b/frontend/pages/castlove/laser-card-studio.vue @@ -6,8 +6,8 @@ { "path": "pages/laser-card-studio/laser-card-studio", "style": { "navigationStyle": "custom", "backgroundColor": "#0a0a0a" } } App 端说明: - 顶栏/底栏使用 statusBarHeight、safeAreaInsets.bottom,避免仅靠 CSS 变量导致刘海屏错位。 - - 预览与导出:圆角 clip →(可选)内置镭射底图 cover → 人物 cover → HSL/幻彩/噪点等;底图可选,置于人物后方;预览用 requestAnimationFrame。 - - iOS WKWebView / Android System WebView:Canvas 合成使用 globalCompositeOperation;本页已移除 DOM 叠层预览。 + - 预览:优先 WebGL 单 Pass 全息着色(FBM + 视角色相 + 色散闪点);失败则 2D Canvas + requestAnimationFrame。导出仍为 2D 离屏 Canvas。 + - iOS WKWebView / Android System WebView:2D 分支用 globalCompositeOperation;本页已移除 DOM 叠层预览。 - 顶栏与预览区 position: sticky,便于长屏下仍可操作顶栏。 - 内置「强全息」默认参数(无调校 UI);保存写入相册或铸爱下单,不再弹窗询问保存调校参数。 --> @@ -24,19 +24,32 @@ - + + + + 换图 - 全息镭射满铺预览 · 保存导出高清图 + WebGL 全息预览 · 保存仍为高清 2D 导出 @@ -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} + */ + _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; diff --git a/frontend/pages/castlove/lenticular-studio.vue b/frontend/pages/castlove/lenticular-studio.vue index 5757e56..ce8df9f 100644 --- a/frontend/pages/castlove/lenticular-studio.vue +++ b/frontend/pages/castlove/lenticular-studio.vue @@ -27,6 +27,7 @@ :gyro-source="gyroSourceLabel" :skip-built-in-touch="true" :tilt-hint-text="''" + :shimmer-mid-opacity="0.16" @simulate="simulate" /> @@ -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() }) @@ -334,6 +390,8 @@ onUnmounted(() => { display: flex; align-items: center; justify-content: center; + contain: layout paint; + transform: translateZ(0); } .canvas-bg { diff --git a/frontend/utils/laser-card/laserPreviewWebgl.js b/frontend/utils/laser-card/laserPreviewWebgl.js new file mode 100644 index 0000000..cb35a6d --- /dev/null +++ b/frontend/utils/laser-card/laserPreviewWebgl.js @@ -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 +} diff --git a/frontend/utils/lenticular-engine.js b/frontend/utils/lenticular-engine.js index 60caddc..c5a6d7a 100644 --- a/frontend/utils/lenticular-engine.js +++ b/frontend/utils/lenticular-engine.js @@ -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)) }