447 lines
15 KiB
Markdown
447 lines
15 KiB
Markdown
# 镭射卡全息光栅效果 Implementation Plan
|
||
|
||
> **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:** 实现实体小卡风格——浅色纯色底 + 抠图人像 + 整卡对角彩虹光栅;预览时彩虹光束动态滑动,五图导出为同参数定格帧。
|
||
|
||
**Architecture:** 新增共享参数模块 `laserGrating.js`(条纹角度/密度/相位/光谱色),2D Canvas 导出与 WebGL 预览共用同一套 preset 字段。图层顺序:纯色底 → 抠图 PNG → 光栅 overlay(整卡,含人脸)→ 极轻磨砂。预览走 `LaserPreviewCanvas` rAF 循环改相位;导出走 `laserBatchExport` 固定 `timeMs`。
|
||
|
||
**Tech Stack:** uni-app Vue3、Canvas 2D(`laserBatchExport.js`)、WebGL1(`laserPreviewWebgl.js`)、现有 `/segment` 抠图链路。
|
||
|
||
**参考视觉:** 实体卡浅薄荷/灰白底 + 135° 平行彩虹条纹,倾斜时光带位移(见用户提供的苏新皓小卡参考图)。
|
||
|
||
**不在本期:** Dify 编排、doodle 手绘装饰层、陀螺仪(列为 Task 6 可选)。
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
| 文件 | 职责 |
|
||
|------|------|
|
||
| `frontend/utils/laser-card/laserGrating.js` | **新建** — preset 归一化、条纹相位/光谱、2D/WebGL 共用常量 |
|
||
| `frontend/utils/laser-card/laserPresets.js` | 五 preset 改为 `backdropTone` / `stripeAngle` / `stripeDensity` / `stripeIntensity` / `holoBlend` |
|
||
| `frontend/utils/laser-card/laserBatchExport.js` | 浅色底 + 增强 `drawDiffractionStripes` + 简化 clean finish |
|
||
| `frontend/utils/laser-card/laserPreviewWebgl.js` | 光栅 fragment shader 替换 fbm 主效果;整卡 overlay |
|
||
| `frontend/components/laser/LaserPreviewCanvas.vue` | 去掉 PNG backdrop;传 grating uniforms;纯色底由 shader 绘制 |
|
||
| `frontend/pages/castlove/laser/laser-result.vue` | 选中卡上方/中央恢复 WebGL 动态预览 |
|
||
| `frontend/composables/useLaserMint.js` | 无改动(已不传固定 backdrop PNG) |
|
||
|
||
---
|
||
|
||
## Task 1: 共享光栅参数模块
|
||
|
||
**Files:**
|
||
- Create: `frontend/utils/laser-card/laserGrating.js`
|
||
- Modify: `frontend/utils/laser-card/laserPresets.js`
|
||
|
||
- [ ] **Step 1: 新建 `laserGrating.js`**
|
||
|
||
```javascript
|
||
/** @typedef {import('./laserPresets.js').PRESET_VARIANTS[0]} LaserPreset */
|
||
|
||
export const DEFAULT_BACKDROP_TONE = '#E8F0ED'
|
||
|
||
export const SPECTRUM_STOPS = [
|
||
{ h: 330, s: 88, l: 58 },
|
||
{ h: 20, s: 92, l: 56 },
|
||
{ h: 52, s: 90, l: 54 },
|
||
{ h: 130, s: 78, l: 52 },
|
||
{ h: 195, s: 85, l: 54 },
|
||
{ h: 265, s: 82, l: 56 },
|
||
]
|
||
|
||
/** @param {Partial<LaserPreset>} preset */
|
||
export function resolveGratingConfig(preset, variantIndex = 0) {
|
||
const p = preset || {}
|
||
return {
|
||
backdropTone: String(p.backdropTone || DEFAULT_BACKDROP_TONE),
|
||
stripeAngle: Number(p.stripeAngle ?? p.angle ?? 135),
|
||
stripeDensity: Math.max(0, Math.min(100, Number(p.stripeDensity ?? p.diffractionDensity ?? 68))),
|
||
stripeIntensity: Math.max(0, Math.min(1, Number(p.stripeIntensity ?? 0.52))),
|
||
holoBlend: p.holoBlend === 'overlay' ? 'overlay' : 'screen',
|
||
flowSpeed: Math.max(0.2, Number(p.flowSpeed ?? 1)),
|
||
stripePhase: variantIndex * 0.37,
|
||
}
|
||
}
|
||
|
||
export function hexToRgb(hex) {
|
||
const h = String(hex || DEFAULT_BACKDROP_TONE).replace('#', '')
|
||
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16)
|
||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||
}
|
||
|
||
export function stripeWidthPx(cw, density) {
|
||
const d = density / 100
|
||
return Math.max(1.2, (3.4 - d * 2.6) * 0.38)
|
||
}
|
||
|
||
export function phaseOffset(timeMs, flowSpeed, stripePhase) {
|
||
return ((timeMs * 0.001 * flowSpeed * 0.85) + stripePhase) % 1
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 更新 `laserPresets.js` 五个 preset**
|
||
|
||
每个 preset 增加/替换字段(删除 `backdrop`、`LASER_STYLE_BACKDROP` 若存在):
|
||
|
||
```javascript
|
||
{
|
||
id: 'dream',
|
||
style: 'dream',
|
||
angle: 36,
|
||
beam: 'prism',
|
||
backdropMode: 'grating', // 新主模式;rich 保留作 legacy
|
||
backdropTone: '#E8F0ED',
|
||
stripeAngle: 132,
|
||
stripeDensity: 70,
|
||
stripeIntensity: 0.5,
|
||
holoBlend: 'screen',
|
||
laserStrength: 68,
|
||
frostIntensity: 18,
|
||
flowSpeed: 0.95,
|
||
},
|
||
// classic: backdropTone '#ECEAF4', stripeAngle 128, stripeDensity 72
|
||
// holoFull: backdropTone '#EEF2F8', stripeAngle 140, stripeDensity 76, stripeIntensity 0.58
|
||
// ice: backdropTone '#E6F0F4', stripeAngle 125, stripeDensity 68
|
||
// sunset: backdropTone '#F2EBE8', stripeAngle 138, stripeDensity 74
|
||
```
|
||
|
||
- [ ] **Step 3: 从 `laserPresets.js` export `resolveGratingConfig` 再导出(可选 re-export)**
|
||
|
||
```javascript
|
||
export { resolveGratingConfig, DEFAULT_BACKDROP_TONE } from './laserGrating.js'
|
||
```
|
||
|
||
- [ ] **Step 4: 手动检查**
|
||
|
||
在浏览器 console 或临时脚本:`resolveGratingConfig(PRESET_VARIANTS[2], 2)` 应返回合法数值对象。
|
||
|
||
---
|
||
|
||
## Task 2: 2D 五图导出 — 浅色底 + 整卡光栅
|
||
|
||
**Files:**
|
||
- Modify: `frontend/utils/laser-card/laserBatchExport.js`
|
||
|
||
- [ ] **Step 1: 引入 grating 模块**
|
||
|
||
```javascript
|
||
import { resolveGratingConfig, hexToRgb, stripeWidthPx, phaseOffset, SPECTRUM_STOPS } from './laserGrating.js'
|
||
```
|
||
|
||
- [ ] **Step 2: 新增 `drawSolidPastelBackdrop`**
|
||
|
||
```javascript
|
||
function drawSolidPastelBackdrop(ctx, cw, ch, backdropTone) {
|
||
ctx.setFillStyle(backdropTone)
|
||
ctx.fillRect(0, 0, cw, ch)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 重写 `drawDiffractionStripes` 支持相位动画(导出用固定 timeMs)**
|
||
|
||
签名改为:`drawDiffractionStripes(ctx, cw, ch, preset, variantIndex, timeMs)`
|
||
|
||
核心逻辑:
|
||
- `const cfg = resolveGratingConfig(preset, variantIndex)`
|
||
- `deg = cfg.stripeAngle`(不再 `angle + 90`,与参考图对角一致)
|
||
- `stripeW = stripeWidthPx(cw, cfg.stripeDensity)`
|
||
- `phase = phaseOffset(timeMs, cfg.flowSpeed, cfg.stripePhase) * stripeW * 6`
|
||
- 旋转坐标后循环画条,`ci = ((i + Math.floor(phase)) % 6 + 6) % 6`
|
||
- `setBlend(ctx, cfg.holoBlend === 'overlay' ? 'overlay' : 'screen')`
|
||
- `ctx.setGlobalAlpha(Math.min(0.55, cfg.stripeIntensity * 0.72))`
|
||
|
||
- [ ] **Step 4: 更新 `drawCleanLaserFinish` → 改名为 `drawGratingFinish`**
|
||
|
||
```javascript
|
||
function drawGratingFinish(ctx, cw, ch, preset, timeMs, variantIndex) {
|
||
drawDiffractionStripes(ctx, cw, ch, preset, variantIndex, timeMs)
|
||
drawFilmGrain(ctx, cw, ch, variantIndex * 313, 12)
|
||
drawFrostMatte(ctx, cw, ch, preset, variantIndex * 1201 + 77, 0.22)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 更新 `drawLaserVariantComposite` 图层**
|
||
|
||
```javascript
|
||
async function drawLaserVariantComposite(ctx, cw, ch, imagePath, layout, preset, variantIndex) {
|
||
const timeMs = Date.now() + variantIndex * 1379
|
||
const cfg = resolveGratingConfig(preset, variantIndex)
|
||
|
||
ctx.setFillStyle(cfg.backdropTone)
|
||
ctx.fillRect(0, 0, cw, ch)
|
||
ctx.save()
|
||
clipRoundRect(ctx, cw, ch)
|
||
|
||
// Layer 1: 纯色浅底(已在 clip 外填过,clip 内再填一次保证边缘)
|
||
drawSolidPastelBackdrop(ctx, cw, ch, cfg.backdropTone)
|
||
|
||
// Layer 2: 抠图人像
|
||
setBlend(ctx, 'source-over')
|
||
ctx.setGlobalAlpha(1)
|
||
try {
|
||
ctx.drawImage(imagePath, layout.dx, layout.dy, layout.dw, layout.dh)
|
||
} catch (e) { /* noop */ }
|
||
|
||
// Layer 3: 整卡光栅 + 轻磨砂
|
||
if (preset.backdropMode === 'rich') {
|
||
drawRichLaserFinish(ctx, cw, ch, preset, timeMs, variantIndex)
|
||
} else {
|
||
drawGratingFinish(ctx, cw, ch, preset, timeMs, variantIndex)
|
||
}
|
||
|
||
ctx.setGlobalAlpha(1)
|
||
setBlend(ctx, 'source-over')
|
||
ctx.restore()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 删除不再使用的 `drawSoftGradientBackdrop`、`drawBackdropVignette`(若仅 clean 用)**
|
||
|
||
- [ ] **Step 7: 验证**
|
||
|
||
走一遍 `create → laser-thinking`:
|
||
- 五张背景应为浅色系,非暗色渐变
|
||
- 每张可见对角彩虹条纹,且覆盖人像区域
|
||
- 五张条纹角度/色调有 preset 差异
|
||
|
||
---
|
||
|
||
## Task 3: WebGL 预览 — 光栅 Shader
|
||
|
||
**Files:**
|
||
- Modify: `frontend/utils/laser-card/laserPreviewWebgl.js`
|
||
|
||
- [ ] **Step 1: 新增 uniforms**
|
||
|
||
```glsl
|
||
uniform float u_stripeAngle;
|
||
uniform float u_stripeDensity;
|
||
uniform float u_stripeIntensity;
|
||
uniform float u_backdropToneR;
|
||
uniform float u_backdropToneG;
|
||
uniform float u_backdropToneB;
|
||
```
|
||
|
||
移除或忽略:`u_hasBackdrop`、`u_back` 纹理采样(预览不再依赖 PNG)。
|
||
|
||
- [ ] **Step 2: 替换 fragment 主色逻辑(保留圆角 discard + photo 采样)**
|
||
|
||
伪代码结构:
|
||
|
||
```glsl
|
||
vec3 baseBg = vec3(u_backdropToneR, u_backdropToneG, u_backdropToneB);
|
||
float personAlpha = pc.a * photoMask;
|
||
vec3 base = mix(baseBg, photoRgb, personAlpha);
|
||
|
||
float ang = u_stripeAngle * 0.01745329252;
|
||
vec2 dir = normalize(vec2(cos(ang), sin(ang)));
|
||
float along = dot(uv - 0.5, dir);
|
||
float freq = mix(18.0, 42.0, u_stripeDensity / 100.0);
|
||
float phase = u_time * 0.001 * u_flowSpeed + u_view.x * 1.8;
|
||
float stripe = abs(sin(along * freq * 3.14159 - phase * 6.28318));
|
||
stripe = smoothstep(0.62, 0.94, stripe);
|
||
|
||
float hue = fract(along * 0.65 + u_time * 0.00015 + u_styleHue / 360.0);
|
||
vec3 rainbow = hsl2rgb(hue * 360.0, 0.82, 0.56);
|
||
float holoAmt = stripe * u_stripeIntensity;
|
||
|
||
// 整卡 screen 混合(含人脸)
|
||
vec3 screenBlend = 1.0 - (1.0 - base) * (1.0 - clamp(rainbow * holoAmt, 0.0, 1.0));
|
||
vec3 col = mix(base, screenBlend, holoAmt * 0.92);
|
||
|
||
// 保留极轻 frost(可选,强度减半)
|
||
```
|
||
|
||
删除或大幅削弱:fbm carrier、cleanMode 对人像的限制、`u_cleanMode` 分支(grating 模式整卡发光)。
|
||
|
||
- [ ] **Step 3: `createLaserPreviewWebgl` — 绑定新 uniform;`uploadBackdrop` 改为 no-op**
|
||
|
||
`render()` 内:
|
||
|
||
```javascript
|
||
const cfg = u.gratingConfig || {}
|
||
gl.uniform1f(loc.u_stripeAngle, cfg.stripeAngle ?? 135)
|
||
gl.uniform1f(loc.u_stripeDensity, cfg.stripeDensity ?? 68)
|
||
gl.uniform1f(loc.u_stripeIntensity, cfg.stripeIntensity ?? 0.52)
|
||
const [r, g, b] = hexToRgb(cfg.backdropTone)
|
||
gl.uniform1f(loc.u_backdropToneR, r / 255)
|
||
// ...
|
||
```
|
||
|
||
- [ ] **Step 4: 验证 shader 编译**
|
||
|
||
H5 打开含 canvas 的页面,控制台无 `shader compile` 错误。
|
||
|
||
---
|
||
|
||
## Task 4: LaserPreviewCanvas 组件对接
|
||
|
||
**Files:**
|
||
- Modify: `frontend/components/laser/LaserPreviewCanvas.vue`
|
||
|
||
- [ ] **Step 1: 删除 `BACKDROP_MAP` 及 backdrop 图片加载逻辑**
|
||
|
||
- [ ] **Step 2: `renderFrame` 传入 gratingConfig**
|
||
|
||
```javascript
|
||
import { resolveGratingConfig } from '@/utils/laser-card/laserGrating.js'
|
||
|
||
const cfg = resolveGratingConfig(preset, 0)
|
||
_webglR.render(timeMs, {
|
||
photoRect: _photoRect,
|
||
gratingConfig: cfg,
|
||
cornerRadiusPx,
|
||
flowSpeed: cfg.flowSpeed,
|
||
styleHue: BEAM_STYLE_HUE[beamEffectId] ?? 0,
|
||
canvasW: cw,
|
||
canvasH: ch,
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: 抠图纹理优先**
|
||
|
||
保持现有逻辑:`sourcePath` 应为 `cutoutImage`(透明 PNG)。
|
||
|
||
- [ ] **Step 4: WebGL fallback**
|
||
|
||
fallback 仍展示 `fallbackPath`(静态 JPG),可加 CSS 类做弱动画(可选,非阻塞)。
|
||
|
||
---
|
||
|
||
## Task 5: laser-result 恢复动态预览
|
||
|
||
**Files:**
|
||
- Modify: `frontend/pages/castlove/laser/laser-result.vue`
|
||
|
||
- [ ] **Step 1: 模板 — 在金字塔上方加预览区(礼盒打开后显示)**
|
||
|
||
```vue
|
||
<view v-if="isGiftOpened && cutoutPath" class="laser-preview-hero" :class="{ visible: isGiftOpened }">
|
||
<LaserPreviewCanvas
|
||
:source-path="cutoutPath"
|
||
:fallback-path="selectedImagePath"
|
||
:preset="selectedPreset"
|
||
:paused="false"
|
||
/>
|
||
</view>
|
||
```
|
||
|
||
- [ ] **Step 2: script — 恢复 computed**
|
||
|
||
```javascript
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import LaserPreviewCanvas from '@/components/laser/LaserPreviewCanvas.vue'
|
||
import { getLaserPresetByIndex } from '@/utils/laser-card/laserPresets.js'
|
||
|
||
const cutoutPath = computed(() => String(craftFormData.value?.cutoutImage || '').trim())
|
||
const selectedImagePath = computed(() => generatedImages.value?.[selectedIndex.value] || '')
|
||
const selectedPreset = computed(() => getLaserPresetByIndex(selectedIndex.value))
|
||
```
|
||
|
||
- [ ] **Step 3: 样式 — 预览区位于卡片金字塔后方/上方,不遮挡选卡**
|
||
|
||
```css
|
||
.laser-preview-hero {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 38%;
|
||
transform: translate(-50%, -50%) scale(0.88);
|
||
width: 320px;
|
||
height: 426px;
|
||
z-index: 15;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.5s ease;
|
||
}
|
||
.laser-preview-hero.visible { opacity: 0.35; } /* 或 1.0 若希望预览为主视觉 */
|
||
```
|
||
|
||
**产品决策(实施时二选一):**
|
||
- **方案 A(推荐):** 预览 opacity 0.35 作背景氛围,金字塔静态 JPG 为主
|
||
- **方案 B:** 选中卡放大位只显示 WebGL,金字塔仅缩略导航
|
||
|
||
默认采用 **方案 A**;若产品要 B,将 `.laser-preview-hero.visible { opacity: 1 }` 且隐藏选中项静态大图。
|
||
|
||
- [ ] **Step 4: `handleSelect` 切换 preset 后预览光栅应变化**
|
||
|
||
切换 `selectedIndex` → `selectedPreset` 变化 → WebGL uniforms 更新。
|
||
|
||
---
|
||
|
||
## Task 6(可选): 陀螺仪 / 触摸驱动光带
|
||
|
||
**Files:**
|
||
- Modify: `frontend/components/laser/LaserPreviewCanvas.vue`
|
||
|
||
- [ ] **Step 1: H5 / 小程序监听 `deviceorientation` 或 touch move**
|
||
|
||
将 `tiltX/tiltY` 写入 render 的 `viewOverride: { x, y }`,映射到 shader `u_view`。
|
||
|
||
- [ ] **Step 2: 无传感器时保持 `u_time` 自动流动**
|
||
|
||
与 Task 3 的 `phase = u_time ... + u_view.x * 1.8` 已兼容。
|
||
|
||
---
|
||
|
||
## Task 7: 验收清单
|
||
|
||
- [ ] **视觉 — 底色:** 五 preset 均为浅灰/薄荷/暖灰纯色,无 PNG 底纹、无暗色渐变
|
||
- [ ] **视觉 — 光栅:** 对角平行彩虹条,半透明覆盖人脸与背景
|
||
- [ ] **动态 — 预览:** result 页 WebGL 光带持续滑动;切换 preset 角度/密度变化
|
||
- [ ] **静态 — 导出:** thinking 五图 JPG 含光栅定格帧;与预览风格一致(不要求像素级相同)
|
||
- [ ] **流程 — 抠图:** segment 失败仍中断 thinking(现有逻辑不变)
|
||
- [ ] **流程 — 铸造:** 选中 JPG 正常上传 mint;无 backdrop 素材也可成功
|
||
- [ ] **回归 — 平台:** H5 主路径可用;WebGL 失败时 fallback 静态图
|
||
|
||
**手动测试命令(H5 dev):**
|
||
|
||
```bash
|
||
cd frontend && npm run dev:h5
|
||
```
|
||
|
||
路径:`铸爱 → 镭射卡 → 上传 → thinking → result`,观察预览动画与五图缩略图。
|
||
|
||
---
|
||
|
||
## 风险与回滚
|
||
|
||
| 风险 | 缓解 |
|
||
|------|------|
|
||
| WebGL 在部分小程序机型不可用 | 保留 fallback 静态 JPG + 可选 CSS 条纹动画 |
|
||
| 光栅过亮盖住人脸 | 调低 `stripeIntensity` 默认 0.45–0.52;blend 用 screen |
|
||
| 导出与预览不一致 | 共用 `laserGrating.js`;验收时对比同 preset 截图 |
|
||
| 回滚 | `backdropMode: 'rich'` 分支保留旧多层效果 |
|
||
|
||
---
|
||
|
||
## 实施顺序与工期估算
|
||
|
||
| 顺序 | Task | 预估 |
|
||
|------|------|------|
|
||
| 1 | Task 1 共享参数 | 0.5h |
|
||
| 2 | Task 2 2D 导出 | 1.5h |
|
||
| 3 | Task 3 WebGL shader | 2h |
|
||
| 4 | Task 4 PreviewCanvas | 1h |
|
||
| 5 | Task 5 result 页 | 0.5h |
|
||
| 6 | Task 7 验收 | 0.5h |
|
||
| 可选 | Task 6 陀螺仪 | 1h |
|
||
|
||
**合计:约 1 个工作日(不含 doodle 装饰与 Dify)。**
|
||
|
||
---
|
||
|
||
## Spec 覆盖自检
|
||
|
||
| 需求 | 对应 Task |
|
||
|------|-----------|
|
||
| 浅色纯色底(参考图2) | Task 1–2 |
|
||
| 整卡对角彩虹条纹 | Task 2–3 |
|
||
| 预览动态光束 | Task 3–5 |
|
||
| 五图静态导出 | Task 2 |
|
||
| 预览/导出分离 | Task 2 + 3(设计文档 §5.5.4) |
|
||
| 不用固定 PNG / 不用 Dify 画图 | Task 2 删除 PNG;计划明确排除 AI 生图 |
|
||
| 抠图在人像层 | 已有 `useLaserBatchGenerate` + composite Layer 2 |
|
||
|
||
**Gap(本期不做):** doodle 装饰层、照片主色自动选底、Dify Phase 2 — 可另开 plan。
|