主要改动: 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>
679 lines
21 KiB
Markdown
679 lines
21 KiB
Markdown
# 光栅卡 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 渲染引擎
|
||
* 单 DrawCall,8 uniforms,3 阶段管线
|
||
*/
|
||
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: 初始化 WebGL(onMounted 中)**
|
||
|
||
```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 |
|