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

15 KiB
Raw Blame History

镭射卡全息光栅效果 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 2DlaserBatchExport.js、WebGL1laserPreviewWebgl.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 增加/替换字段(删除 backdropLASER_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.js export resolveGratingConfig 再导出(可选 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: 删除不再使用的 drawSoftGradientBackdropdrawBackdropVignette(若仅 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_hasBackdropu_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 — 绑定新 uniformuploadBackdrop 改为 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 后预览光栅应变化

切换 selectedIndexselectedPreset 变化 → 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.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。