主要改动: 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>
18 KiB
光栅卡 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 |
视角→权重映射,stripShares,EMA 平滑 | 追加 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 等)
│
├─ 计算 stripShares(N=2 时各 0.5;N=3 时分 background/foreground/mid)
├─ 计算 rawWeights(视角位置 u 落在各层覆盖区的距离)
├─ smoothstep 平滑 → smoothedW
├─ 找到 dominant 层
├─ prevLayerGhost + nonDominantResidual 保底
├─ 按 parallaxFactor 计算各层 offsetX(CSS 降级用)
│
└─ 汇出 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 中按序执行):
webglPreferred为false→ 直接使用 CSS DOM 路径,不尝试 WebGLwebglPreferred为true→ 尝试创建 WebGL1 上下文- 创建成功 → 使用 WebGL 路径,发射
@ready事件 - 创建失败 → 自动降级到 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 |
LenticularWebGLEngine 类(8 uniforms,3 阶段管线) |
修改
| # | 文件 | 改动 |
|---|---|---|
| 3 | frontend/utils/lenticular-engine.js → 移入 utils/lenticular-card/ |
文件迁移 + computeRenderState() 末尾汇出 engineUniforms |
| 4 | frontend/composables/useLenticularPreview.js |
追加 engineUniforms 到返回值 |
| 5 | frontend/components/lenticular/LenticularCard.vue |
双路径渲染 + 引擎生命周期 |
不改
useLenticularStudioTilt.jsHolographicCard.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 — 核心引擎
- 建
frontend/utils/lenticular-card/目录,将lenticular-engine.js迁入 - 编写
lenticular-webgl-shaders.js(VERT_SRC + FRAG_SRC,仅条纹交织 + 圆角暗角) - 编写
lenticular-webgl-engine.js(LenticularWebGLEngine类,8 uniform 管理) lenticular-engine.js追加engineUniforms汇出useLenticularPreview.js追加返回值
Phase 2 — 组件集成
- 重构
LenticularCard.vue:双路径 template + WebGL 引擎生命周期 - 联调:验证陀螺仪 → engineUniforms → uniform → stripe shader 响应
Phase 3 — 回归
- 验证 CSS DOM 降级(
webglPreferred=false) - 验证 2 图层各场景(创建页拖动、结果页陀螺仪)
- 低端安卓真机测试
十、DPR 与纹理管理策略
10.1 DPR 管理
- Canvas 物理分辨率 = CSS 尺寸 ×
min(devicePixelRatio, 2) - Fragment shader 中
u_dpr用于圆角 SDF 计算 - resize 时重建 viewport
10.2 纹理更新
当 layers[].src 变化时:
- 加载新图片 →
engine.uploadTexture(index, image)更新对应纹理单元 - 纹理使用
gl.LINEAR_MIPMAP_LINEAR采样,上传后生成 mipmap - 初始化或任一纹理未就绪时,已就绪的纹理正常显示,未就绪的通道采样纯黑色
- 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)无需修改
- 镭射卡所有文件不受影响
- 后端无改动