# 光栅卡 WebGL 引擎整合设计 > **创建日期:** 2026-06-26 > **项目:** TopFans 星卡 · 光栅卡渲染升级 > **状态:** 设计审核中 > **版本:** v1.0 --- ## 一、背景与目标 ### 1.1 现状 光栅卡目前采用**CSS DOM 渲染路径**:`LenticularEngine`(纯 JS)计算每层权重/偏移,`LenticularCard.vue` 通过 `v-for` 渲染 N 个 `` + `` 标签,用 `translate3d` + `opacity` 模拟柱镜光栅的视角切换效果。 镭射卡已有独立的 `HolographicCard.vue` + `HolographicEngine`(WebGL),但**该组件是做单图全息光效的,不解决多图条纹交织问题**。 ### 1.2 问题 | 问题 | 说明 | |---|---| | 无真正的条纹级像素交织 | 当前是叠化(opacity crossfade),不是物理柱镜的"逐像素切换" | | N>2 时性能线性衰减 | 每多一层就多一个 `` 合成层 | | 两套引擎职责混淆 | HolographicEngine 被误认为"光栅的 WebGL 方案",实际它只处理单图全息 | ### 1.3 目标 - 用 WebGL 实现真正的条纹级像素交织 - 保留现有 `LenticularEngine` 的成熟权重算法和陀螺仪稳定逻辑 - 不破坏现有 CSS DOM 降级路径 - 代码组织与镭射卡 `utils/laser-card/` 对仗 --- ## 二、视觉目标(Before / After) ### 现在:CSS opacity 叠化 倾斜手机时,用户看到的是两张图以不同透明度叠加: ``` 倾斜 0° 倾斜 -15° 倾斜 +15° ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ A 图 100% │ │ A 图 60% │ │ A 图 0% │ │ B 图 0% │ → │ B 图 40% │ → │ B 图 100% │ │ │ │ (半透叠化) │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ 效果:A 渐隐、B 渐显,全程两张图同时存在,类似"交叉淡入淡出" 问题:没有"一张图上、一张图下"的切换感 ``` ### 改后:WebGL 条纹级像素交织 每个像素只来自一张图,相邻像素交替采样 A/B: ``` 倾斜 0° 倾斜 -15° 倾斜 +15° ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ A│B│A│B│A│B │ │ A│A│B│B│A│A │ │ B│A│B│A│B│A │ │ A│B│A│B│A│B │ → │ A│A│B│B│A│A │ → │ B│A│B│A│B│A │ │ A│B│A│B│A│B │ │ A│A│B│B│A│A │ │ B│A│B│A│B│A │ └──────────────┘ └──────────────┘ └──────────────┘ 每个格子 = 1 个像素 效果:倾斜时 A 图区域缩小、B 图区域扩大,条纹边界非此即彼 视觉:类似真实物理柱镜片的"啪一下亮出来" ``` ### 一句话总结 > **从"两张图互相半透叠化"变为"逐像素条纹交替",视觉上从"数字感"变为"物理柱镜卡片的质感"。** --- ## 三、关键决策 | 决策 | 选项 | 结论 | |---|---|---| | 图层数 | A) 2 层 / B) 3 层 / C) 动态 | **A) 2 层**(背景 + 主体) | | WebGL 降级 | A) CSS fallback / B) 静态图 / C) 不做 | **A) CSS DOM fallback** | | 技术路线 | A) 新引擎 / B) 扩展现有 / C) PixiJS | **A) 新 LenticularWebGLEngine** | | Composable 职责 | 仅数据逻辑,不碰 WebGL 生命周期 | 与组件现有模式一致 | | 目录层级 | 与镭射卡 `utils/laser-card/` 对仗 | `utils/lenticular-card/` | --- ## 四、整体架构 ``` ┌───────────────────────┐ │ useLenticularStudio- │ ← 不改 │ Tilt.js (陀螺仪采集) │ └──────────┬────────────┘ │ gamma/beta ┌──────────▼────────────┐ │ LenticularEngine │ ← 追加 engineUniforms 汇出 │ (视角→权重/偏移动画) │ 不破坏原输出 └──────────┬────────────┘ │ parallax, phase, density ┌──────────▼────────────┐ │ useLenticularPreview │ ← 追加 engineUniforms 返回值 │ (composable) │ └──────────┬────────────┘ │ ┌────────────────────┴────────────────────┐ │ WebGL 可用 │ CSS 不可用 ▼ ▼ ┌──────────────────────┐ ┌──────────────────────┐ │ LenticularWebGLEngine│ │ (现有 CSS DOM 路径) │ │ (新增,组件内部管理) │ │ layerTransforms → │ │ 8 个 uniforms → │ │ translate3d+opacity │ │ 1 draw call │ │ │ └──────────┬───────────┘ └──────────────────────┘ │ ▼ Fragment Shader 管线: ① UV parallax 偏移 ② stripe 条纹交织 (mix textureA/B) ③ 输出修饰 (圆角/暗角/edgeAA) ``` ### 4.1 模块职责 | 模块 | 职责 | 改否 | |---|---|---| | `useLenticularStudioTilt.js` | 陀螺仪采集 + 跳变拒绝 + EMA 平滑 | 不改 | | `LenticularEngine` | 视角→权重映射,stripShares,EMA 平滑 | 追加 `engineUniforms` 汇出,不破坏原输出 | `engineUniforms` 格式: ```js { parallax: float, // -1~1,视差偏移量 phase: float, // 0~1,条纹相位(由 displayGamma 映射) density: float, // ≈0.08~0.3,条纹密度(由 lenticularPitchPx 算出) } ``` | `useLenticularPreview` | 管理 rAF 循环,暴露 layerTransforms + engineUniforms | 追加 engineUniforms 返回值 | | `LenticularWebGLEngine` | **新增**。WebGL init/compile/resize/rAF/destroy | — | | `LenticularCard.vue` | 渲染入口,自选 WebGL/CSS 路径 | 重写 template,保留 fallback | --- ## 五、完整调用链路(从陀螺仪到屏幕像素) 以下是一个倾斜动作从上到下经过的全部环节: ``` 用户手指在卡面上拖动 / 手机倾斜 │ ▼ [硬件层] deviceorientation 事件 (陀螺仪) 或 touchstart/touchmove (拖动) │ 原始角度/坐标 ▼ [useLenticularStudioTilt.js] ← 不改 跳变拒绝:abs(raw - prev) > 15° → clamp(prev ± 15°) EMA 平滑 (快通道 α=0.25, 慢通道 α=0.08) delta 死区:abs < 0.5° → 0 │ 输出: { gamma: -1~1, beta: 0 } ▼ [LenticularEngine.updateDisplayStable()] sensorDeadzoneStrength → softAttenuateNearZero │ 输出: displayGamma: -1~1 ▼ [LenticularEngine.computeRenderState()] 入参: displayGamma, physics (sensitivity, smoothness, depth, pitch 等) │ ├─ 计算 stripShares(N=2 时各 0.5;N=3 时分 background/foreground/mid) ├─ 计算 rawWeights(视角位置 u 落在各层覆盖区的距离) ├─ smoothstep 平滑 → smoothedW ├─ 找到 dominant 层 ├─ prevLayerGhost + nonDominantResidual 保底 ├─ 按 parallaxFactor 计算各层 offsetX(CSS 降级用) │ └─ 汇出 engineUniforms(新增部分,不破坏以上原有输出): { parallax: displayGamma × sensitivity × depth × 0.42, // -1~1 phase: (displayGamma × 0.38 + 0.5) % 1, // 0~1 density: 50 / lenticularPitchPx, // ≈3~6 } ▼ [useLenticularPreview.tick()] ← rAF 循环(唯一驱动源) │ 每帧 16ms 执行一次 │ ├─ [CSS 降级路径]: applyLayerTransformsFromRenderState() │ → 更新 layerTransforms.value → Vue 响应式 → DOM re-render │ └─ [WebGL 路径]: 调用 registerOnTick 回调 │ ▼ [LenticularWebGLEngine.setUniforms(uniforms)] │ 设置 8 个 uniform: │ u_parallax, u_phase, u_density ← 来自 engineUniforms │ u_textureA, u_textureB ← 纹理绑定 │ u_cornerRadius, u_resolution, u_dpr ← 来自组件 props + canvas ▼ [LenticularWebGLEngine.draw()] │ ├─ gl.useProgram(program) ← 编译好的 shader ├─ gl.activeTexture(GL.TEXTURE0) ← 绑定纹理 A ├─ gl.activeTexture(GL.TEXTURE1) ← 绑定纹理 B ├─ gl.uniform*(loc, value) × 8 ← 写入 uniform(含 vec2) ├─ gl.bindBuffer + gl.vertexAttribPointer └─ gl.drawArrays(GL_TRIANGLES, 0, 6) ← 1 个 draw call │ ▼ [GPU Fragment Shader 并行执行] 管线 3 阶段(见 §6.2): ① UV parallax 偏移 ② stripe 条纹交织 → mix(textureA, textureB, mask) ③ 输出修饰 (圆角/暗角/edgeAA) │ ▼ [屏幕像素] Canvas 上的最终像素显示给用户 每帧 ~200 万像素(以 375×500 CSS 尺寸 × DPR=2 计) ``` ### 关键时序 | 环节 | 耗时估计 | 说明 | |---|---|---| | 陀螺仪采集 + 平滑 | < 0.5ms | JS 纯算术 | | computeRenderState() + engineUniforms 汇出 | < 0.3ms | 10 层循环 + 浮点运算 | | setUniforms + draw | < 1ms | WebGL 调用开销 | | Fragment Shader 执行 | < 2ms | GPU 并行,200 万像素 | | 总计单帧 | < 4ms | 远低于 16ms 帧预算 | --- ## 六、Fragment Shader 管线 ### 6.1 Uniform 清单(精简) | uniform | 类型 | 来源 | 说明 | |---|---|---|---| | `u_textureA` | sampler2D | layer[0] 底图 | 背景纹理 | | `u_textureB` | sampler2D | layer[1] 前景 | 人物纹理 | | `u_parallax` | float | LenticularEngine | 视差偏移 -1~1 | | `u_phase` | float | LenticularEngine | 条纹相位 0~1 | | `u_density` | float | LenticularEngine | 条纹密度 ≈0.08~0.3 | | `u_cornerRadius` | float | props | 圆角半径 | | `u_resolution` | vec2 | canvas size | 分辨率 | | `u_dpr` | float | devicePixelRatio | 像素比 | 共 **8 个 uniform**,无全息/时间相关参数。 ### 6.2 管线(3 阶段) #### ① UV 变换 ```glsl vec2 uv = v_texCoord; uv.x += u_parallax * 0.03; float scale = 1.0 + abs(u_parallax) * 0.015; uv = (uv - 0.5) * scale + 0.5; ``` #### ② 条纹交织(核心,仅此一个像素级效果) ```glsl float stripe = fract(uv.x * u_density + u_phase); float mask = smoothstep(0.45, 0.55, stripe); vec4 baseColor = mix(texture2D(u_textureA, uv), texture2D(u_textureB, uv), mask); ``` **每个像素只来自一张图**,相邻像素交替采样 A/B。`u_density` 控制条纹密度(等效每毫米条纹数),`u_phase` 控制视角偏移时条纹组的左右滑动。这是整个引擎**唯一**的像素级视觉效果。 #### ③ 输出修饰 ```glsl // 圆角裁剪 float sdf = roundedRectSDF(pn, halfRes - cornerRadius, cornerRadius); if (sdf > 1.5) discard; // 暗角 baseColor *= 1.0 - pow(clamp(length(pnNorm) * 1.1, 0, 1), 2.8) * 0.35; // 边缘抗锯齿 float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf); gl_FragColor = vec4(baseColor, cornerMask * edgeAA); ``` 无全息效果、无噪声、无色散、无高光、无划痕、无珠光。 --- ## 七、组件 API ### 7.1 LenticularCard.vue ```vue ``` 使用方不感知渲染路径差异。 **WebGL 可用性判断**(在 `onMounted` 中按序执行): 1. `webglPreferred` 为 `false` → 直接使用 CSS DOM 路径,不尝试 WebGL 2. `webglPreferred` 为 `true` → 尝试创建 WebGL1 上下文 3. 创建成功 → 使用 WebGL 路径,发射 `@ready` 事件 4. 创建失败 → 自动降级到 CSS DOM 路径,发射 `@error` 事件(组件内不抛白屏) **WebGL 初始化失败后重试**:不重试。一次失败代表该设备不支持 WebGL,重试无意义。 ### 组件内部结构 ```vue ``` --- ## 八、文件结构 ### 新增 | # | 文件 | 职责 | |---|---|---| | 1 | `frontend/utils/lenticular-card/lenticular-webgl-shaders.js` | VERT_SRC + FRAG_SRC(约 80 行,无全息效果) | | 2 | `frontend/utils/lenticular-card/lenticular-webgl-engine.js` | `LenticularWebGLEngine` 类(8 uniforms,3 阶段管线) | ### 修改 | # | 文件 | 改动 | |---|---|---| | 3 | `frontend/utils/lenticular-engine.js` → 移入 `utils/lenticular-card/` | 文件迁移 + `computeRenderState()` 末尾汇出 `engineUniforms` | | 4 | `frontend/composables/useLenticularPreview.js` | 追加 `engineUniforms` 到返回值 | | 5 | `frontend/components/lenticular/LenticularCard.vue` | 双路径渲染 + 引擎生命周期 | ### 不改 - `useLenticularStudioTilt.js` - `HolographicCard.vue` / `HolographicEngine` / `holographic-shaders.js` - 所有页面层(`lenticular-create.vue` / `lenticular-result.vue` / `lenticular-thinking.vue`) - 所有镭射卡文件 ### 目录对仗 ``` utils/laser-card/ utils/lenticular-card/ ├── laserGrating.js ├── lenticular-engine.js ← 迁入 ├── laserPreviewWebgl.js ├── lenticular-webgl-engine.js ← 新增 ├── laserBatchExport.js ├── lenticular-webgl-shaders.js ← 新增 ├── laserPresets.js └── ...(后续光栅工具) ├── gacha.js ├── stylePool.js └── ... ``` --- ## 九、实施步骤 ### Phase 1 — 核心引擎 1. 建 `frontend/utils/lenticular-card/` 目录,将 `lenticular-engine.js` 迁入 2. 编写 `lenticular-webgl-shaders.js`(VERT_SRC + FRAG_SRC,仅条纹交织 + 圆角暗角) 3. 编写 `lenticular-webgl-engine.js`(`LenticularWebGLEngine` 类,8 uniform 管理) 4. `lenticular-engine.js` 追加 `engineUniforms` 汇出 5. `useLenticularPreview.js` 追加返回值 ### Phase 2 — 组件集成 6. 重构 `LenticularCard.vue`:双路径 template + WebGL 引擎生命周期 7. 联调:验证陀螺仪 → engineUniforms → uniform → stripe shader 响应 ### Phase 3 — 回归 8. 验证 CSS DOM 降级(`webglPreferred=false`) 9. 验证 2 图层各场景(创建页拖动、结果页陀螺仪) 10. 低端安卓真机测试 --- ## 十、DPR 与纹理管理策略 ### 10.1 DPR 管理 - Canvas 物理分辨率 = CSS 尺寸 × `min(devicePixelRatio, 2)` - Fragment shader 中 `u_dpr` 用于圆角 SDF 计算 - resize 时重建 viewport ### 10.2 纹理更新 当 `layers[].src` 变化时: 1. 加载新图片 → `engine.uploadTexture(index, image)` 更新对应纹理单元 2. 纹理使用 `gl.LINEAR_MIPMAP_LINEAR` 采样,上传后生成 mipmap 3. 初始化或任一纹理未就绪时,已就绪的纹理正常显示,未就绪的通道采样纯黑色 4. **shader 无需重新编译**——uniform 和纹理单元绑定不变,只换纹理数据 **纹理内存峰值**:2 张纹理 × `(CSS宽×DPR) × (CSS高×DPR) × 4 bytes`。若卡片 CSS 尺寸为 375×500、DPR=2,则单纹理约 1.5MB,合计约 3MB。在移动端可控。 ### 10.3 生命周期 ``` 组件 onMounted 1. new LenticularWebGLEngine(canvas) 2. engine.init(textureA, textureB) // layer[0] + layer[1] 3. 注册 onTick 回调:engine.setUniforms() + engine.draw() 4. useLenticularPreview.startRenderLoop() // 启动唯一 rAF 组件 watch layers[].src → 加载新图片 → engine.uploadTexture(index, newImage) 组件 onUnmounted → useLenticularPreview.stopRenderLoop() → engine.destroy() ``` ### 10.4 rAF 循环 — 单一驱动 **不能有两套独立的 rAF**(一个来自 composable,一个来自 engine),否则帧同步紊乱。 改为 composable 的 rAF 作为唯一驱动源: ``` composable.tick() → feedSimulatedTilt → computeRenderState() → applyLayerTransformsFromRenderState() // CSS 降级用 → callback(engineUniforms) // WebGL 用 └── component 注册的 onTick └── engine.setUniforms(uniforms) └── engine.draw() → requestAnimationFrame(tick) ``` 实现方式:`useLenticularPreview` 新增一个 `onTick` 回调注册机制,组件在 `onMounted` 时传入: ```js // LenticularCard.vue const { ..., registerOnTick } = useLenticularPreview(layersRef) registerOnTick((uniforms) => { if (webglEngine) { webglEngine.setUniforms(uniforms) webglEngine.draw() } }) ``` 这样整个渲染管线**单帧内同步**完成,没有多 rAF 竞态。 --- ## 十一、不影响的范围 - HolographicCard / HolographicEngine 保持独立,不参与本设计 - 页面层(lenticular-create/lenticular-result/lenticular-thinking)无需修改 - 镭射卡所有文件不受影响 - 后端无改动