topfans/docs/superpowers/specs/2026-06-26-lenticular-webgl-engine-design.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

473 lines
18 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 引擎整合设计
> **创建日期:** 2026-06-26
> **项目:** TopFans 星卡 · 光栅卡渲染升级
> **状态:** 设计审核中
> **版本:** v1.0
---
## 一、背景与目标
### 1.1 现状
光栅卡目前采用**CSS DOM 渲染路径**`LenticularEngine`(纯 JS计算每层权重/偏移,`LenticularCard.vue` 通过 `v-for` 渲染 N 个 `<view>` + `<image>` 标签,用 `translate3d` + `opacity` 模拟柱镜光栅的视角切换效果。
镭射卡已有独立的 `HolographicCard.vue` + `HolographicEngine`WebGL但**该组件是做单图全息光效的,不解决多图条纹交织问题**。
### 1.2 问题
| 问题 | 说明 |
|---|---|
| 无真正的条纹级像素交织 | 当前是叠化opacity crossfade不是物理柱镜的"逐像素切换" |
| N>2 时性能线性衰减 | 每多一层就多一个 `<image>` 合成层 |
| 两套引擎职责混淆 | HolographicEngine 被误认为"光栅的 WebGL 方案",实际它只处理单图全息 |
### 1.3 目标
- 用 WebGL 实现真正的条纹级像素交织
- 保留现有 `LenticularEngine` 的成熟权重算法和陀螺仪稳定逻辑
- 不破坏现有 CSS DOM 降级路径
- 代码组织与镭射卡 `utils/laser-card/` 对仗
---
## 二、视觉目标Before / After
### 现在CSS opacity 叠化
倾斜手机时,用户看到的是两张图以不同透明度叠加:
```
倾斜 0° 倾斜 -15° 倾斜 +15°
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ A 图 100% │ │ A 图 60% │ │ A 图 0% │
│ B 图 0% │ → │ B 图 40% │ → │ B 图 100% │
│ │ │ (半透叠化) │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
效果A 渐隐、B 渐显,全程两张图同时存在,类似"交叉淡入淡出"
问题:没有"一张图上、一张图下"的切换感
```
### 改后WebGL 条纹级像素交织
每个像素只来自一张图,相邻像素交替采样 A/B
```
倾斜 0° 倾斜 -15° 倾斜 +15°
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ A│B│A│B│A│B │ │ A│A│B│B│A│A │ │ B│A│B│A│B│A │
│ A│B│A│B│A│B │ → │ A│A│B│B│A│A │ → │ B│A│B│A│B│A │
│ A│B│A│B│A│B │ │ A│A│B│B│A│A │ │ B│A│B│A│B│A │
└──────────────┘ └──────────────┘ └──────────────┘
每个格子 = 1 个像素
效果:倾斜时 A 图区域缩小、B 图区域扩大,条纹边界非此即彼
视觉:类似真实物理柱镜片的"啪一下亮出来"
```
### 一句话总结
> **从"两张图互相半透叠化"变为"逐像素条纹交替",视觉上从"数字感"变为"物理柱镜卡片的质感"。**
---
## 三、关键决策
| 决策 | 选项 | 结论 |
|---|---|---|
| 图层数 | A) 2 层 / B) 3 层 / C) 动态 | **A) 2 层**(背景 + 主体) |
| WebGL 降级 | A) CSS fallback / B) 静态图 / C) 不做 | **A) CSS DOM fallback** |
| 技术路线 | A) 新引擎 / B) 扩展现有 / C) PixiJS | **A) 新 LenticularWebGLEngine** |
| Composable 职责 | 仅数据逻辑,不碰 WebGL 生命周期 | 与组件现有模式一致 |
| 目录层级 | 与镭射卡 `utils/laser-card/` 对仗 | `utils/lenticular-card/` |
---
## 四、整体架构
```
┌───────────────────────┐
│ useLenticularStudio- │ ← 不改
│ Tilt.js (陀螺仪采集) │
└──────────┬────────────┘
│ gamma/beta
┌──────────▼────────────┐
│ LenticularEngine │ ← 追加 engineUniforms 汇出
│ (视角→权重/偏移动画) │ 不破坏原输出
└──────────┬────────────┘
│ parallax, phase, density
┌──────────▼────────────┐
│ useLenticularPreview │ ← 追加 engineUniforms 返回值
│ (composable) │
└──────────┬────────────┘
┌────────────────────┴────────────────────┐
│ WebGL 可用 │ CSS 不可用
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ LenticularWebGLEngine│ │ (现有 CSS DOM 路径) │
│ (新增,组件内部管理) │ │ layerTransforms → │
│ 8 个 uniforms → │ │ translate3d+opacity │
│ 1 draw call │ │ │
└──────────┬───────────┘ └──────────────────────┘
Fragment Shader 管线:
① UV parallax 偏移
② stripe 条纹交织 (mix textureA/B)
③ 输出修饰 (圆角/暗角/edgeAA)
```
### 4.1 模块职责
| 模块 | 职责 | 改否 |
|---|---|---|
| `useLenticularStudioTilt.js` | 陀螺仪采集 + 跳变拒绝 + EMA 平滑 | 不改 |
| `LenticularEngine` | 视角→权重映射stripSharesEMA 平滑 | 追加 `engineUniforms` 汇出,不破坏原输出 |
`engineUniforms` 格式:
```js
{
parallax: float, // -1~1视差偏移量
phase: float, // 0~1条纹相位由 displayGamma 映射)
density: float, // ≈0.08~0.3,条纹密度(由 lenticularPitchPx 算出)
}
```
| `useLenticularPreview` | 管理 rAF 循环,暴露 layerTransforms + engineUniforms | 追加 engineUniforms 返回值 |
| `LenticularWebGLEngine` | **新增**。WebGL init/compile/resize/rAF/destroy | — |
| `LenticularCard.vue` | 渲染入口,自选 WebGL/CSS 路径 | 重写 template保留 fallback |
---
## 五、完整调用链路(从陀螺仪到屏幕像素)
以下是一个倾斜动作从上到下经过的全部环节:
```
用户手指在卡面上拖动 / 手机倾斜
[硬件层]
deviceorientation 事件 (陀螺仪) 或 touchstart/touchmove (拖动)
│ 原始角度/坐标
[useLenticularStudioTilt.js] ← 不改
跳变拒绝abs(raw - prev) > 15° → clamp(prev ± 15°)
EMA 平滑 (快通道 α=0.25, 慢通道 α=0.08)
delta 死区abs < 0.5° → 0
│ 输出: { gamma: -1~1, beta: 0 }
[LenticularEngine.updateDisplayStable()]
sensorDeadzoneStrength → softAttenuateNearZero
│ 输出: displayGamma: -1~1
[LenticularEngine.computeRenderState()]
入参: displayGamma, physics (sensitivity, smoothness, depth, pitch 等)
├─ 计算 stripSharesN=2 时各 0.5N=3 时分 background/foreground/mid
├─ 计算 rawWeights视角位置 u 落在各层覆盖区的距离)
├─ smoothstep 平滑 → smoothedW
├─ 找到 dominant 层
├─ prevLayerGhost + nonDominantResidual 保底
├─ 按 parallaxFactor 计算各层 offsetXCSS 降级用)
└─ 汇出 engineUniforms新增部分不破坏以上原有输出:
{
parallax: displayGamma × sensitivity × depth × 0.42, // -1~1
phase: (displayGamma × 0.38 + 0.5) % 1, // 0~1
density: 50 / lenticularPitchPx, // ≈3~6
}
[useLenticularPreview.tick()] ← rAF 循环(唯一驱动源)
│ 每帧 16ms 执行一次
├─ [CSS 降级路径]: applyLayerTransformsFromRenderState()
│ → 更新 layerTransforms.value → Vue 响应式 → DOM re-render
└─ [WebGL 路径]: 调用 registerOnTick 回调
[LenticularWebGLEngine.setUniforms(uniforms)]
│ 设置 8 个 uniform:
│ u_parallax, u_phase, u_density ← 来自 engineUniforms
│ u_textureA, u_textureB ← 纹理绑定
│ u_cornerRadius, u_resolution, u_dpr ← 来自组件 props + canvas
[LenticularWebGLEngine.draw()]
├─ gl.useProgram(program) ← 编译好的 shader
├─ gl.activeTexture(GL.TEXTURE0) ← 绑定纹理 A
├─ gl.activeTexture(GL.TEXTURE1) ← 绑定纹理 B
├─ gl.uniform*(loc, value) × 8 ← 写入 uniform含 vec2
├─ gl.bindBuffer + gl.vertexAttribPointer
└─ gl.drawArrays(GL_TRIANGLES, 0, 6) ← 1 个 draw call
[GPU Fragment Shader 并行执行]
管线 3 阶段(见 §6.2:
① UV parallax 偏移
② stripe 条纹交织 → mix(textureA, textureB, mask)
③ 输出修饰 (圆角/暗角/edgeAA)
[屏幕像素]
Canvas 上的最终像素显示给用户
每帧 ~200 万像素(以 375×500 CSS 尺寸 × DPR=2 计)
```
### 关键时序
| 环节 | 耗时估计 | 说明 |
|---|---|---|
| 陀螺仪采集 + 平滑 | < 0.5ms | JS 纯算术 |
| computeRenderState() + engineUniforms 汇出 | < 0.3ms | 10 层循环 + 浮点运算 |
| setUniforms + draw | < 1ms | WebGL 调用开销 |
| Fragment Shader 执行 | < 2ms | GPU 并行200 万像素 |
| 总计单帧 | < 4ms | 远低于 16ms 帧预算 |
---
## 六、Fragment Shader 管线
### 6.1 Uniform 清单(精简)
| uniform | 类型 | 来源 | 说明 |
|---|---|---|---|
| `u_textureA` | sampler2D | layer[0] 底图 | 背景纹理 |
| `u_textureB` | sampler2D | layer[1] 前景 | 人物纹理 |
| `u_parallax` | float | LenticularEngine | 视差偏移 -1~1 |
| `u_phase` | float | LenticularEngine | 条纹相位 0~1 |
| `u_density` | float | LenticularEngine | 条纹密度 0.08~0.3 |
| `u_cornerRadius` | float | props | 圆角半径 |
| `u_resolution` | vec2 | canvas size | 分辨率 |
| `u_dpr` | float | devicePixelRatio | 像素比 |
**8 个 uniform**无全息/时间相关参数
### 6.2 管线3 阶段)
#### ① UV 变换
```glsl
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;
```
#### ② 条纹交织(核心,仅此一个像素级效果)
```glsl
float stripe = fract(uv.x * u_density + u_phase);
float mask = smoothstep(0.45, 0.55, stripe);
vec4 baseColor = mix(texture2D(u_textureA, uv),
texture2D(u_textureB, uv),
mask);
```
**每个像素只来自一张图**相邻像素交替采样 A/B。`u_density` 控制条纹密度等效每毫米条纹数`u_phase` 控制视角偏移时条纹组的左右滑动这是整个引擎**唯一**的像素级视觉效果
#### ③ 输出修饰
```glsl
// 圆角裁剪
float sdf = roundedRectSDF(pn, halfRes - cornerRadius, cornerRadius);
if (sdf > 1.5) discard;
// 暗角
baseColor *= 1.0 - pow(clamp(length(pnNorm) * 1.1, 0, 1), 2.8) * 0.35;
// 边缘抗锯齿
float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf);
gl_FragColor = vec4(baseColor, cornerMask * edgeAA);
```
无全息效果无噪声无色散无高光无划痕无珠光
---
## 七、组件 API
### 7.1 LenticularCard.vue
```vue
<LenticularCard
:layers="layers" // [{id, src, parallaxFactor, opacity}, ...]
:transforms.sync="..." // 保留CSS 降级时需要
gyro-source="simulation" // "simulation" | "gyro"
tilt-hint-text="倾斜手机预览"
corner-radius="24" // 圆角
webgl-preferred // Boolean默认 true
@simulate @ready @error
/>
```
使用方不感知渲染路径差异
**WebGL 可用性判断** `onMounted` 中按序执行
1. `webglPreferred` `false` 直接使用 CSS DOM 路径不尝试 WebGL
2. `webglPreferred` `true` 尝试创建 WebGL1 上下文
3. 创建成功 使用 WebGL 路径发射 `@ready` 事件
4. 创建失败 自动降级到 CSS DOM 路径发射 `@error` 事件组件内不抛白屏
**WebGL 初始化失败后重试**不重试一次失败代表该设备不支持 WebGL重试无意义
### 组件内部结构
```vue
<template>
<view class="lenticular-container">
<canvas v-if="useWebgl" ref="webglCanvas" ... />
<view v-else class="lenticular-fallback">
<!-- 保留现有 CSS DOM 渲染代码 -->
<view v-for="layer in layers" :key="layer.id" class="fallback-layer"
:style="getLayerStyle(layer)">
<image :src="layer.src" mode="aspectFill" />
</view>
<view class="fallback-shimmer" />
<view class="fallback-rim" />
</view>
</view>
</template>
```
---
## 八、文件结构
### 新增
| # | 文件 | 职责 |
|---|---|---|
| 1 | `frontend/utils/lenticular-card/lenticular-webgl-shaders.js` | VERT_SRC + FRAG_SRC 80 无全息效果 |
| 2 | `frontend/utils/lenticular-card/lenticular-webgl-engine.js` | `LenticularWebGLEngine` 8 uniforms3 阶段管线 |
### 修改
| # | 文件 | 改动 |
|---|---|---|
| 3 | `frontend/utils/lenticular-engine.js` 移入 `utils/lenticular-card/` | 文件迁移 + `computeRenderState()` 末尾汇出 `engineUniforms` |
| 4 | `frontend/composables/useLenticularPreview.js` | 追加 `engineUniforms` 到返回值 |
| 5 | `frontend/components/lenticular/LenticularCard.vue` | 双路径渲染 + 引擎生命周期 |
### 不改
- `useLenticularStudioTilt.js`
- `HolographicCard.vue` / `HolographicEngine` / `holographic-shaders.js`
- 所有页面层`lenticular-create.vue` / `lenticular-result.vue` / `lenticular-thinking.vue`
- 所有镭射卡文件
### 目录对仗
```
utils/laser-card/ utils/lenticular-card/
├── laserGrating.js ├── lenticular-engine.js ← 迁入
├── laserPreviewWebgl.js ├── lenticular-webgl-engine.js ← 新增
├── laserBatchExport.js ├── lenticular-webgl-shaders.js ← 新增
├── laserPresets.js └── ...(后续光栅工具)
├── gacha.js
├── stylePool.js
└── ...
```
---
## 九、实施步骤
### Phase 1 — 核心引擎
1. `frontend/utils/lenticular-card/` 目录 `lenticular-engine.js` 迁入
2. 编写 `lenticular-webgl-shaders.js`VERT_SRC + FRAG_SRC仅条纹交织 + 圆角暗角
3. 编写 `lenticular-webgl-engine.js``LenticularWebGLEngine` 8 uniform 管理
4. `lenticular-engine.js` 追加 `engineUniforms` 汇出
5. `useLenticularPreview.js` 追加返回值
### Phase 2 — 组件集成
6. 重构 `LenticularCard.vue`双路径 template + WebGL 引擎生命周期
7. 联调验证陀螺仪 engineUniforms uniform stripe shader 响应
### Phase 3 — 回归
8. 验证 CSS DOM 降级`webglPreferred=false`
9. 验证 2 图层各场景创建页拖动结果页陀螺仪
10. 低端安卓真机测试
---
## 十、DPR 与纹理管理策略
### 10.1 DPR 管理
- Canvas 物理分辨率 = CSS 尺寸 × `min(devicePixelRatio, 2)`
- Fragment shader `u_dpr` 用于圆角 SDF 计算
- resize 时重建 viewport
### 10.2 纹理更新
`layers[].src` 变化时
1. 加载新图片 `engine.uploadTexture(index, image)` 更新对应纹理单元
2. 纹理使用 `gl.LINEAR_MIPMAP_LINEAR` 采样上传后生成 mipmap
3. 初始化或任一纹理未就绪时已就绪的纹理正常显示未就绪的通道采样纯黑色
4. **shader 无需重新编译**——uniform 和纹理单元绑定不变只换纹理数据
**纹理内存峰值**2 张纹理 × `(CSS宽×DPR) × (CSS高×DPR) × 4 bytes`若卡片 CSS 尺寸为 375×500DPR=2则单纹理约 1.5MB合计约 3MB在移动端可控
### 10.3 生命周期
```
组件 onMounted
1. new LenticularWebGLEngine(canvas)
2. engine.init(textureA, textureB) // layer[0] + layer[1]
3. 注册 onTick 回调engine.setUniforms() + engine.draw()
4. useLenticularPreview.startRenderLoop() // 启动唯一 rAF
组件 watch layers[].src
→ 加载新图片 → engine.uploadTexture(index, newImage)
组件 onUnmounted
→ useLenticularPreview.stopRenderLoop()
→ engine.destroy()
```
### 10.4 rAF 循环 — 单一驱动
**不能有两套独立的 rAF**一个来自 composable一个来自 engine否则帧同步紊乱
改为 composable rAF 作为唯一驱动源
```
composable.tick() → feedSimulatedTilt → computeRenderState()
→ applyLayerTransformsFromRenderState() // CSS 降级用
→ callback(engineUniforms) // WebGL 用
└── component 注册的 onTick
└── engine.setUniforms(uniforms)
└── engine.draw()
→ requestAnimationFrame(tick)
```
实现方式`useLenticularPreview` 新增一个 `onTick` 回调注册机制组件在 `onMounted` 时传入
```js
// LenticularCard.vue
const { ..., registerOnTick } = useLenticularPreview(layersRef)
registerOnTick((uniforms) => {
if (webglEngine) {
webglEngine.setUniforms(uniforms)
webglEngine.draw()
}
})
```
这样整个渲染管线**单帧内同步**完成没有多 rAF 竞态
---
## 十一、不影响的范围
- HolographicCard / HolographicEngine 保持独立不参与本设计
- 页面层lenticular-create/lenticular-result/lenticular-thinking无需修改
- 镭射卡所有文件不受影响
- 后端无改动