# 镭射卡全息光栅效果 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} 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 ``` - [ ] **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。