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

18 KiB
Raw Blame History

光栅卡 WebGL 引擎整合设计

创建日期: 2026-06-26 项目: TopFans 星卡 · 光栅卡渲染升级 状态: 设计审核中 版本: v1.0


一、背景与目标

1.1 现状

光栅卡目前采用CSS DOM 渲染路径LenticularEngine(纯 JS计算每层权重/偏移,LenticularCard.vue 通过 v-for 渲染 N 个 <view> + <image> 标签,用 translate3d + opacity 模拟柱镜光栅的视角切换效果。

镭射卡已有独立的 HolographicCard.vue + HolographicEngineWebGL该组件是做单图全息光效的,不解决多图条纹交织问题

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 格式:

{
  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 变换

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 = mix(texture2D(u_textureA, uv),
                     texture2D(u_textureB, uv),
                     mask);

每个像素只来自一张图,相邻像素交替采样 A/B。u_density 控制条纹密度(等效每毫米条纹数),u_phase 控制视角偏移时条纹组的左右滑动。这是整个引擎唯一的像素级视觉效果。

③ 输出修饰

// 圆角裁剪
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

<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. webglPreferredfalse → 直接使用 CSS DOM 路径,不尝试 WebGL
  2. webglPreferredtrue → 尝试创建 WebGL1 上下文
  3. 创建成功 → 使用 WebGL 路径,发射 @ready 事件
  4. 创建失败 → 自动降级到 CSS DOM 路径,发射 @error 事件(组件内不抛白屏)

WebGL 初始化失败后重试:不重试。一次失败代表该设备不支持 WebGL重试无意义。

组件内部结构

<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 LenticularWebGLEngine8 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.jsVERT_SRC + FRAG_SRC仅条纹交织 + 圆角暗角)
  3. 编写 lenticular-webgl-engine.jsLenticularWebGLEngine8 uniform 管理)
  4. lenticular-engine.js 追加 engineUniforms 汇出
  5. useLenticularPreview.js 追加返回值

Phase 2 — 组件集成

  1. 重构 LenticularCard.vue:双路径 template + WebGL 引擎生命周期
  2. 联调:验证陀螺仪 → engineUniforms → uniform → stripe shader 响应

Phase 3 — 回归

  1. 验证 CSS DOM 降级(webglPreferred=false
  2. 验证 2 图层各场景(创建页拖动、结果页陀螺仪)
  3. 低端安卓真机测试

十、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×500、DPR=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 时传入:

// LenticularCard.vue
const { ..., registerOnTick } = useLenticularPreview(layersRef)
registerOnTick((uniforms) => {
  if (webglEngine) {
    webglEngine.setUniforms(uniforms)
    webglEngine.draw()
  }
})

这样整个渲染管线单帧内同步完成,没有多 rAF 竞态。


十一、不影响的范围

  • HolographicCard / HolographicEngine 保持独立,不参与本设计
  • 页面层lenticular-create/lenticular-result/lenticular-thinking无需修改
  • 镭射卡所有文件不受影响
  • 后端无改动