topfans/docs/superpowers/plans/2026-06-26-lenticular-webgl-engine.md
Lenticular Studio Agent 65ce6bba12 feat: Dify 部署脚本修复 + AI 搭子 MVP 接入
主要改动:

fix(docker/dify-deploy): 修复脚本核心功能
- heredoc 单引号 bug: 'ENVEOF' 改为 ENVEOF,变量正确展开
- 端口默认值 8083/8084/8085 对齐 .env.prod 生产配置
- 加 dc_cmd() 兼容 docker-compose v1/v2 plugin
- openssl rand 生成强随机密码与 SECRET_KEY(42 字符)
- install 跳过已存在 .env,保护用户配置(管理员密码/SECRET_KEY)
- read -p < /dev/tty 兼容非 tty 环境(CI/CD)
- show-config 改用 DIFY_NGINX_PORT(nginx 入口)而非 APP_WEB_PORT

docs(mvp-design): 修正 §3.2 workflow inputs 描述
- 实际只有 query,删除错误的 user_id input 声明
- 节点序列图同步更新

feat(aiChatService): 新增 Dify 客户端与适配器
- service/dify_client.go: Dify Workflow 调用 + SSE 解析
- service/dify_adapter.go: 与现有 chat_service 桥接
- provider/ai_chat_provider.go: Dubbo 入口简化
- main.go: 装配 ConversationRepository + DifyClient

feat(migrations): 新增 AI 搭子会话表 ai_chat.sql
- ai_conversations / ai_messages 表 + 索引

docs: 新增 Dify 集成设计文档
- 2026-06-29-ai-chat-dify-mvp-design.md (MVP 实施级)
- 2026-06-29-ai-chat-dify-integration-v2-design.md (V2 演进路线图)
- docs/dify/角角.yml (Workflow DSL 导出)

config: 更新 env 模板与 docker 配置
- backend/.env.example: DIFY_* 环境变量声明
- docker/.env.prod: DIFY_API_BASE 对齐 8083
- docker/build.sh: 微调
- CLAUDE.md: 项目规范补充

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 12:32:34 +08:00

21 KiB
Raw Blame History

光栅卡 WebGL 引擎实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 用 WebGL 实现光栅卡的条纹级像素交织,替代当前 CSS DOM opacity 叠化方案

Architecture: 新增 LenticularWebGLEngineWebGL 引擎类)和片段着色器(条纹交织 + 圆角暗角),保留 LenticularEngine 的现有权重算法并在 computeRenderState() 末尾汇出 engineUniformsuseLenticularPreview 增加 registerOnTick 回调实现唯一 rAF 驱动,LenticularCard.vue 改为 WebGL / CSS DOM 双路径渲染

Tech Stack: WebGL1 (ES 100), uniapp APP-PLUS, Vue 3 composable

Global Constraints

  • 仅适配 APP-PLUS无 H5/小程序降级
  • 2 层图(背景 + 主体),不支持 3+
  • 无全息效果、无噪声、无色散、无安全区域
  • LenticularEngine 原有输出 renderState + layerTransforms 不破坏
  • 文件组织与 utils/laser-card/ 对仗,放 utils/lenticular-card/
  • WebGL 不可用时自动降级到现有 CSS DOM 路径
  • 陀螺仪代码 useLenticularStudioTilt.js 不修改

文件结构

新增

文件 职责
frontend/utils/lenticular-card/lenticular-webgl-shaders.js VERT_SRC + FRAG_SRC~80 行 GLSL
frontend/utils/lenticular-card/lenticular-webgl-engine.js LenticularWebGLEngine 类(~200 行)

修改

文件 改动
frontend/utils/lenticular-engine.js → 移入 utils/lenticular-card/ 文件迁移 + computeRenderState() 末尾汇出 engineUniforms
frontend/composables/useLenticularPreview.js 追加 engineUniforms 返回值 + registerOnTick
frontend/components/lenticular/LenticularCard.vue 双路径渲染 + 引擎生命周期

不改

useLenticularStudioTilt.jsHolographicCard.vueHolographicEngine、全部页面层(lenticular-create/result/thinking.vue


Task 1: 建立 utils/lenticular-card/ 目录并迁移 lenticular-engine.js

Files:

  • Create: frontend/utils/lenticular-card/
  • Move: frontend/utils/lenticular-engine.jsfrontend/utils/lenticular-card/lenticular-engine.js

Interfaces:

  • Consumes: 无(纯文件迁移)

  • Produces: lenticular-engine.jsutils/lenticular-card/ 目录下可被 import 引用

  • Step 1: 创建目录

mkdir -p frontend/utils/lenticular-card
  • Step 2: 复制文件到新位置
cp frontend/utils/lenticular-engine.js frontend/utils/lenticular-card/lenticular-engine.js
  • Step 3: 确认所有引用旧路径的地方

搜索 @/utils/lenticular-engine.js 找到所有引用:

grep -rn "lenticular-engine" frontend/ --include="*.js" --include="*.vue"

预期至少命中:

  • frontend/composables/useLenticularPreview.js
  • frontend/components/lenticular/LenticularCard.vue
  • 可能的测试文件

记录所有路径,后续 task 逐个更新。

  • Step 4: 删除旧文件
rm frontend/utils/lenticular-engine.js

Task 2: 编写 lenticular-webgl-shaders.js

Files:

  • Create: frontend/utils/lenticular-card/lenticular-webgl-shaders.js

Interfaces:

  • Consumes: 无

  • Produces: export const VERT_SRCexport const FRAG_SRC,供 LenticularWebGLEngine 使用

  • Step 1: 实现 VERT_SRC + FRAG_SRC

/**
 * 光栅卡 WebGL 着色器
 * 管线UV 偏移 → 条纹交织 → 输出修饰
 * 无全息效果
 */

export const VERT_SRC = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;

void main() {
  v_texCoord = a_texCoord;
  gl_Position = vec4(a_position * 2.0 - 1.0, 0.0, 1.0);
}
`

export const FRAG_SRC = `
precision highp float;

varying vec2 v_texCoord;

uniform sampler2D u_textureA;
uniform sampler2D u_textureB;
uniform float u_parallax;
uniform float u_phase;
uniform float u_density;
uniform float u_cornerRadius;
uniform vec2 u_resolution;
uniform float u_dpr;

// ---- 圆角 SDF ----
float roundedRectSDF(vec2 p, vec2 halfSize, float r) {
  vec2 q = abs(p) - halfSize + r;
  return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}

void main() {
  // ① UV 偏移
  vec2 uv = v_texCoord;
  uv.x += u_parallax * 0.03;
  float scale = 1.0 + abs(u_parallax) * 0.015;
  uv = (uv - 0.5) * scale + 0.5;

  // ② 条纹交织 —— 每个像素只来自一张图
  float stripe = fract(uv.x * u_density + u_phase);
  float mask = smoothstep(0.45, 0.55, stripe);
  vec4 baseColor = texture2D(u_textureA, uv);
  vec4 frontColor = texture2D(u_textureB, uv);
  vec4 finalColor = mix(baseColor, frontColor, mask);

  // ③ 输出修饰
  vec2 halfRes = u_resolution * 0.5;
  vec2 pn = (uv - 0.5) * u_resolution;
  float cornerRadPx = u_cornerRadius * u_dpr;
  float sdf = roundedRectSDF(pn, halfRes - cornerRadPx, cornerRadPx);
  if (sdf > 1.5) discard;

  // 暗角
  vec2 pnNorm = pn / max(halfRes.x, halfRes.y);
  float vignette = 1.0 - pow(clamp(length(pnNorm) * 1.1, 0.0, 1.0), 2.8) * 0.35;
  finalColor.rgb *= vignette;

  // 边缘抗锯齿
  float cornerMask = 1.0 - smoothstep(-1.5, 1.5, sdf);
  float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf);
  float alpha = cornerMask * edgeAA;
  finalColor = clamp(finalColor, 0.0, 1.0);
  gl_FragColor = vec4(finalColor.rgb, alpha);
}
`

Task 3: 编写 lenticular-webgl-engine.js

Files:

  • Create: frontend/utils/lenticular-card/lenticular-webgl-engine.js

Interfaces:

  • Consumes: VERT_SRC, FRAG_SRC from ./lenticular-webgl-shaders.js; loadTextureImage from @/utils/laser-card/laserPreviewWebgl.js

  • Produces: class LenticularWebGLEngine 暴露 init(textureA, textureB), resize(w, h), setUniforms({parallax, phase, density}), draw(), uploadTexture(index, image), destroy()

  • Step 1: 实现 LenticularWebGLEngine 类

/**
 * 光栅卡 WebGL 渲染引擎
 * 单 DrawCall8 uniforms3 阶段管线
 */
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
import { VERT_SRC, FRAG_SRC } from './lenticular-webgl-shaders.js'

export class LenticularWebGLEngine {
  constructor(canvas) {
    this.canvas = canvas
    this.gl = null
    this.program = null
    this.uniformLocs = {}
    this.textures = [null, null]
    this._dpr = 1
    this._uniforms = { parallax: 0, phase: 0, density: 0.16 }
    this._initialized = false
    this._destroyed = false
  }

  init(textureSrcA, textureSrcB) {
    if (this._initialized || this._destroyed) return false
    const gl = this.canvas.getContext('webgl', {
      alpha: true, antialias: true, premultipliedAlpha: false,
      powerPreference: 'high-performance',
    })
    if (!gl) return false
    this.gl = gl
    this._dpr = Math.min(window.devicePixelRatio || 1, 2)
    this._syncSize()

    // 编译 shader
    const vs = this._compileShader(gl.VERTEX_SHADER, VERT_SRC)
    const fs = this._compileShader(gl.FRAGMENT_SHADER, FRAG_SRC)
    if (!vs || !fs) return false
    this.program = gl.createProgram()
    gl.attachShader(this.program, vs)
    gl.attachShader(this.program, fs)
    gl.linkProgram(this.program)
    if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) return false

    // 缓存 uniform 位置
    const names = ['u_textureA', 'u_textureB', 'u_parallax', 'u_phase', 'u_density',
                   'u_cornerRadius', 'u_resolution', 'u_dpr']
    for (const name of names) {
      this.uniformLocs[name] = gl.getUniformLocation(this.program, name)
    }

    // 全屏四边形
    const verts = new Float32Array([0,0, 1,0, 0,1, 1,0, 0,1, 1,1])
    const buf = gl.createBuffer()
    gl.bindBuffer(gl.ARRAY_BUFFER, buf)
    gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW)

    const posLoc = gl.getAttribLocation(this.program, 'a_position')
    const tcLoc = gl.getAttribLocation(this.program, 'a_texCoord')
    gl.enableVertexAttribArray(posLoc)
    gl.enableVertexAttribArray(tcLoc)
    gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0)
    gl.vertexAttribPointer(tcLoc, 2, gl.FLOAT, false, 16, 8)

    // 纹理
    this.textures[0] = gl.createTexture()
    this.textures[1] = gl.createTexture()
    gl.useProgram(this.program)
    gl.uniform1i(this.uniformLocs.u_textureA, 0)
    gl.uniform1i(this.uniformLocs.u_textureB, 1)

    // 加载纹理
    if (textureSrcA) this._loadTexture(0, textureSrcA)
    if (textureSrcB) this._loadTexture(1, textureSrcB)

    this._initialized = true
    return true
  }

  resize(cssW, cssH) {
    const w = Math.round(cssW * this._dpr)
    const h = Math.round(cssH * this._dpr)
    if (this.canvas.width === w && this.canvas.height === h) return
    this.canvas.width = w
    this.canvas.height = h
    this.gl && this.gl.viewport(0, 0, w, h)
  }

  _syncSize() {
    const rect = this.canvas.getBoundingClientRect()
    if (rect.width && rect.height) this.resize(rect.width, rect.height)
  }

  setUniforms(u) {
    Object.assign(this._uniforms, u)
  }

  draw() {
    const gl = this.gl
    if (!gl || !this._initialized) return

    gl.useProgram(this.program)
    gl.uniform1f(this.uniformLocs.u_parallax, this._uniforms.parallax)
    gl.uniform1f(this.uniformLocs.u_phase, this._uniforms.phase)
    gl.uniform1f(this.uniformLocs.u_density, this._uniforms.density)
    gl.uniform1f(this.uniformLocs.u_cornerRadius, this._uniforms.cornerRadius || 24)
    gl.uniform2f(this.uniformLocs.u_resolution, this.canvas.width, this.canvas.height)
    gl.uniform1f(this.uniformLocs.u_dpr, this._dpr)
    gl.drawArrays(gl.TRIANGLES, 0, 6)
  }

  uploadTexture(index, image) {
    if (index < 0 || index > 1 || !this.gl) return
    this._loadTexture(index, image)
  }

  async _loadTexture(index, src) {
    const gl = this.gl
    const img = typeof src === 'string'
      ? await loadTextureImage(src)
      : src
    if (!img) return
    gl.activeTexture(gl.TEXTURE0 + index)
    gl.bindTexture(gl.TEXTURE_2D, this.textures[index])
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
    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.generateMipmap(gl.TEXTURE_2D)
  }

  _compileShader(type, src) {
    const gl = this.gl
    const s = gl.createShader(type)
    gl.shaderSource(s, src)
    gl.compileShader(s)
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
      console.error('[LenticularWebGLEngine] shader compile error:', gl.getShaderInfoLog(s))
      return null
    }
    return s
  }

  destroy() {
    const gl = this.gl
    if (gl) {
      if (this.program) gl.deleteProgram(this.program)
      for (const t of this.textures) { if (t) gl.deleteTexture(t) }
    }
    this._destroyed = true
    this._initialized = false
  }
}

Task 4: lenticular-engine.js 追加 engineUniforms 汇出

Files:

  • Modify: frontend/utils/lenticular-card/lenticular-engine.jscomputeRenderState() 末尾

Interfaces:

  • Consumes: 已有 this.renderStatethis.physicsthis.displayGamma

  • Produces: computeRenderState() 返回值新增字段 engineUniforms: { parallax, phase, density }

  • Step 1: computeRenderState() 末尾追加汇出代码

找到 lenticular-engine.jscomputeRenderState()return this.renderState 行,在其之前插入 engineUniforms 计算。注意 this.renderState.stripePhaseShift 已经由原有逻辑写入。

// lenticular-engine.js — computeRenderState() 末尾,在 return 之前追加

// ===== WebGL engineUniforms 汇出(不破坏原有输出)=====
const parallax = this.displayGamma * (this.physics.tiltSensitivity / 100) * (this.physics.parallaxDepth || 0) * 0.42
const sensitivity = this.physics.tiltSensitivity / 100
const stripePhaseShift = this.renderState.stripePhaseShift != null
  ? this.renderState.stripePhaseShift
  : this.displayGamma * (0.38 + 0.52 * sensitivity)
const phase = (stripePhaseShift + 1) / 2  // -1~1 → 0~1
const pitchPx = this.renderState.lenticularPitchPx || 16
const density = 50 / pitchPx

this.renderState.engineUniforms = {
  parallax: clamp(parallax, -1, 1),
  phase: clamp(phase, 0, 1),
  density: clamp(density, 0.08, 0.3),
}

return this.renderState

注意:函数顶部已有 clamp,无需重复定义。


Task 5: useLenticularPreview.js 追加 engineUniforms + registerOnTick

Files:

  • Modify: frontend/composables/useLenticularPreview.js
  • Modify: 更新 Task 1 中旧路径引用为 @/utils/lenticular-card/lenticular-engine

Interfaces:

  • Consumes: LenticularEnginecomputeRenderState() 返回的 engineUniforms

  • Produces: 新增返回值 engineUniformsrefregisterOnTick(fn) 函数

  • Step 1: 更新 import 路径

// 旧
import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-engine.js'
// 新
import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-card/lenticular-engine.js'
  • Step 2: 新增 registerOnTick 机制

useLenticularPreview 函数体内,let rafId = null 之后新增:

let onTickCallback = null

export function registerOnTick(fn) {
  onTickCallback = typeof fn === 'function' ? fn : null
}
  • Step 3: tick() 中调用 onTickCallback

tick() 函数从:

function tick() {
  try {
    const ls = getLayersArray()
    const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
    applyLayerTransformsFromRenderState(ls, renderState)
  } catch (e) {
    console.error('[useLenticularPreview] tick failed', e)
  }
  rafId = nextFrame(tick)
}

改为:

function tick() {
  try {
    const ls = getLayersArray()
    const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
    applyLayerTransformsFromRenderState(ls, renderState)
    // WebGL onTick
    if (onTickCallback && renderState.engineUniforms) {
      onTickCallback(renderState.engineUniforms)
    }
  } catch (e) {
    console.error('[useLenticularPreview] tick failed', e)
  }
  rafId = nextFrame(tick)
}
  • Step 4: 追加到返回值
// 现有 return 末尾追加
return {
  // ... 原有返回值
  registerOnTick,
}

Task 6: 重构 LenticularCard.vue — 双路径渲染

Files:

  • Modify: frontend/components/lenticular/LenticularCard.vue
  • Modify: 更新 import 路径为 @/utils/lenticular-card/lenticular-engine

Interfaces:

  • Consumes: useLenticularPreview 返回的 layerTransforms + registerOnTick

  • Consumes: LenticularWebGLEngine from @/utils/lentricular-card/lenticular-webgl-engine

  • Consumes: loadTextureImage from @/utils/laser-card/laserPreviewWebgl.js

  • Step 1: 更新 script setup 头部

<script setup>
import { computed, getCurrentInstance, onMounted, ref, watch, nextTick } from 'vue'
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
import { LenticularWebGLEngine } from '@/utils/lenticular-card/lenticular-webgl-engine.js'
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'

const props = defineProps({
  layers: { type: Array, required: true },
  transforms: { type: Object, default: () => ({}) },
  gyroSource: { type: String, default: 'simulation' },
  tiltHintText: { type: String, default: '倾斜手机预览' },
  approximatePreview: { type: Boolean, default: true },
  skipBuiltInTouch: { type: Boolean, default: false },
  // WebGL 专属
  cornerRadius: { type: Number, default: 24 },
  webglPreferred: { type: Boolean, default: true },
  shimmerMidOpacity: { type: Number, default: 0.1 },
})

const emit = defineEmits(['simulate', 'ready', 'error'])
  • Step 2: 新增 WebGL 状态 + 引擎生命周期
const pageProxy = getCurrentInstance()?.proxy
const showHint = ref(true)
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
const cardRect = ref(null)

// ---- WebGL 状态 ----
const webglCanvas = ref(null)
const useWebgl = ref(false)
let webglEngine = null
let resizeObserver = null
  • Step 3: 接入 composable + 注册 onTick
const layersRef = computed(() => props.layers)
const { physics, layerTransforms, stripeRender, gyro, simulate, relax, snapSimulatedTilt,
        startRenderLoop, stopRenderLoop, registerOnTick } = useLenticularPreview(layersRef)
  • Step 4: 初始化 WebGLonMounted 中)
async function initWebGL() {
  if (!props.webglPreferred) return false
  const canvas = webglCanvas.value
  if (!canvas) return false

  const engine = new LenticularWebGLEngine(canvas)
  // 获取两张图的 src
  const srcA = props.layers[0]?.src || ''
  const srcB = props.layers[1]?.src || ''
  const ok = engine.init(srcA, srcB)
  if (!ok) {
    engine.destroy()
    return false
  }
  webglEngine = engine

  // 监听尺寸变化
  if (typeof ResizeObserver !== 'undefined') {
    resizeObserver = new ResizeObserver((entries) => {
      for (const e of entries) {
        const { width, height } = e.contentRect
        if (engine && width > 0 && height > 0) engine.resize(width, height)
      }
    })
    ro.observe(canvas.parentElement || canvas)
  }

  // 注册 onTick
  registerOnTick((uniforms) => {
    if (webglEngine) {
      webglEngine.setUniforms({
        ...uniforms,
        cornerRadius: props.cornerRadius,
      })
      webglEngine.draw()
    }
  })

  useWebgl.value = true
  emit('ready')
  return true
}
  • Step 5: onMounted 决策 WebGL / CSS
onMounted(async () => {
  const webglOk = await initWebGL()
  if (!webglOk) {
    // 降级到 CSS DOM
    setTimeout(() => { void refreshRect() }, 0)
    emit('error', new Error('WebGL init failed, fallback to CSS'))
  }
  startRenderLoop()
})

onUnmounted(() => {
  stopRenderLoop()
  if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null }
  if (webglEngine) {
    webglEngine.destroy()
    webglEngine = null
  }
})
  • Step 6: 更新 template 为双路径
<template>
  <view class="card-container">
    <!-- WebGL 路径 -->
    <canvas
      v-if="useWebgl"
      ref="webglCanvas"
      class="card-canvas"
      :style="{ width: '100%', height: '100%' }"
      @touchstart.stop="onFrameTouchStart"
      @touchmove.stop.prevent="onFrameTouchMove"
      @touchend.stop="onFrameTouchEnd"
      @touchcancel.stop="onFrameTouchEnd"
    />
    <!-- CSS 降级路径 -->
    <template v-else>
      <view
        :id="cardId"
        class="card-frame"
        @touchstart.stop="onFrameTouchStart"
        @touchmove.stop.prevent="onFrameTouchMove"
        @touchend.stop="onFrameTouchEnd"
        @touchcancel.stop="onFrameTouchEnd"
      >
        <view class="card-body" :style="cardRotateStyle">
          <view
            v-for="layer in layers"
            :key="layer.id"
            class="card-layer"
            :style="getLayerStyle(layer)"
          >
            <image
              v-if="layer.src"
              class="card-layer-img"
              :src="layer.src"
              mode="aspectFill"
              @error="onImageError"
              @load="onImageLoad"
            />
          </view>
          <view class="lenticular-shimmer" :style="shimmerStyle" />
          <view class="lenticular-tint" />
          <view class="glass-rim" />
          <view class="vignette" />
        </view>
      </view>
    </template>
  </view>
</template>
  • Step 7: 添加 CSS
<style scoped>
.card-canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* 保留原有 CSS DOM 路径的全部样式 */
.card-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; perspective: 1000px; position: relative; }
.card-frame { position: relative; height: 100%; width: 100%; overflow: visible; }
.card-body { position: absolute; inset: 0; transform-style: preserve-3d; box-shadow: 0 20px 50px rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.18); background-color: #060e20; will-change: transform; }
.card-layer { position: absolute; width: 100%; height: 100%; will-change: transform, opacity; background-size: cover; background-position: center; }
.card-layer-img { width: 100%; height: 100%; }
.lenticular-shimmer { position: absolute; inset: 0; pointer-events: none; opacity: 0.85; }
.lenticular-tint { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(135deg, rgba(221,183,255,0.1) 0%, transparent 35%, transparent 65%, rgba(76,215,246,0.1) 100%); opacity: 0.6; }
.glass-rim { position: absolute; inset: 0; border-radius: 24px; border-top: 1px solid rgba(221,183,255,0.42); box-shadow: inset 0 0 40px rgba(255,255,255,0.05); pointer-events: none; }
.vignette { position: absolute; inset: 0; background: linear-gradient(to top, rgba(6,14,32,0.82), transparent 50%); pointer-events: none; }
</style>

验收标准

# 验证项 方法
1 WebGL 条纹交织 创建页打开,两张图倾斜时条纹交替,无叠化模糊
2 CSS DOM 降级 设置 webglPreferred=false,页面与改动前一致
3 陀螺仪驱动 结果页用真机倾斜,条纹随角度平滑移动,无抖动
4 生命周期 页面进出各 3 次,无 WebGL context lost 报错
5 Android 低端机 测试机输出 fps > 50,倾斜响应 < 50ms