topfans/docs/superpowers/plans/2026-05-27-laser-holographic-grating.md
2026-06-03 22:19:22 +08:00

447 lines
15 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.

# 镭射卡全息光栅效果 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.450.52blend 用 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 12 |
| 整卡对角彩虹条纹 | Task 23 |
| 预览动态光束 | Task 35 |
| 五图静态导出 | Task 2 |
| 预览/导出分离 | Task 2 + 3设计文档 §5.5.4 |
| 不用固定 PNG / 不用 Dify 画图 | Task 2 删除 PNG计划明确排除 AI 生图 |
| 抠图在人像层 | 已有 `useLaserBatchGenerate` + composite Layer 2 |
**Gap本期不做** doodle 装饰层、照片主色自动选底、Dify Phase 2 — 可另开 plan。