15 KiB
镭射卡全息光栅效果 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
/** @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 若存在):
{
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.jsexportresolveGratingConfig再导出(可选 re-export)
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 模块
import { resolveGratingConfig, hexToRgb, stripeWidthPx, phaseOffset, SPECTRUM_STOPS } from './laserGrating.js'
- Step 2: 新增
drawSolidPastelBackdrop
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
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图层
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
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 采样)
伪代码结构:
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() 内:
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
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: 模板 — 在金字塔上方加预览区(礼盒打开后显示)
<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
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: 样式 — 预览区位于卡片金字塔后方/上方,不遮挡选卡
.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):
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。