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

679 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 光栅卡 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:** 新增 `LenticularWebGLEngine`WebGL 引擎类)和片段着色器(条纹交织 + 圆角暗角),保留 `LenticularEngine` 的现有权重算法并在 `computeRenderState()` 末尾汇出 `engineUniforms``useLenticularPreview` 增加 `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.js`、`HolographicCard.vue`、`HolographicEngine`、全部页面层(`lenticular-create/result/thinking.vue`
---
## Task 1: 建立 `utils/lenticular-card/` 目录并迁移 `lenticular-engine.js`
**Files:**
- Create: `frontend/utils/lenticular-card/`
- Move: `frontend/utils/lenticular-engine.js``frontend/utils/lenticular-card/lenticular-engine.js`
**Interfaces:**
- Consumes: 无(纯文件迁移)
- Produces: `lenticular-engine.js``utils/lenticular-card/` 目录下可被 `import` 引用
- [ ] **Step 1: 创建目录**
```bash
mkdir -p frontend/utils/lenticular-card
```
- [ ] **Step 2: 复制文件到新位置**
```bash
cp frontend/utils/lenticular-engine.js frontend/utils/lenticular-card/lenticular-engine.js
```
- [ ] **Step 3: 确认所有引用旧路径的地方**
搜索 `@/utils/lenticular-engine.js` 找到所有引用:
```bash
grep -rn "lenticular-engine" frontend/ --include="*.js" --include="*.vue"
```
预期至少命中:
- `frontend/composables/useLenticularPreview.js`
- `frontend/components/lenticular/LenticularCard.vue`
- 可能的测试文件
记录所有路径,后续 task 逐个更新。
- [ ] **Step 4: 删除旧文件**
```bash
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_SRC``export const FRAG_SRC`,供 `LenticularWebGLEngine` 使用
- [ ] **Step 1: 实现 VERT_SRC + FRAG_SRC**
```js
/**
* 光栅卡 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 类**
```js
/**
* 光栅卡 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.js``computeRenderState()` 末尾
**Interfaces:**
- Consumes: 已有 `this.renderState`、`this.physics`、`this.displayGamma`
- Produces: `computeRenderState()` 返回值新增字段 `engineUniforms: { parallax, phase, density }`
- [ ] **Step 1: computeRenderState() 末尾追加汇出代码**
找到 `lenticular-engine.js``computeRenderState()``return this.renderState` 行,在其之前插入 engineUniforms 计算。注意 `this.renderState.stripePhaseShift` 已经由原有逻辑写入。
```js
// 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: `LenticularEngine``computeRenderState()` 返回的 `engineUniforms`
- Produces: 新增返回值 `engineUniforms`ref、`registerOnTick(fn)` 函数
- [ ] **Step 1: 更新 import 路径**
```js
// 旧
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` 之后新增:
```js
let onTickCallback = null
export function registerOnTick(fn) {
onTickCallback = typeof fn === 'function' ? fn : null
}
```
- [ ] **Step 3: tick() 中调用 onTickCallback**
`tick()` 函数从:
```js
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)
}
```
改为:
```js
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: 追加到返回值**
```js
// 现有 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 头部**
```js
<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 状态 + 引擎生命周期**
```js
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**
```js
const layersRef = computed(() => props.layers)
const { physics, layerTransforms, stripeRender, gyro, simulate, relax, snapSimulatedTilt,
startRenderLoop, stopRenderLoop, registerOnTick } = useLenticularPreview(layersRef)
```
- [ ] **Step 4: 初始化 WebGLonMounted 中)**
```js
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**
```js
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 为双路径**
```vue
<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**
```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 |