# 光栅卡 WebGL 引擎实施计划 > **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:** 用 WebGL 实现光栅卡的条纹级像素交织,替代当前 CSS DOM opacity 叠化方案 **Architecture:** 新增 `LenticularWebGLEngine`(WebGL 引擎类)和片段着色器(条纹交织 + 圆角暗角),保留 `LenticularEngine` 的现有权重算法并在 `computeRenderState()` 末尾汇出 `engineUniforms`,`useLenticularPreview` 增加 `registerOnTick` 回调实现唯一 rAF 驱动,`LenticularCard.vue` 改为 WebGL / CSS DOM 双路径渲染 **Tech Stack:** WebGL1 (ES 100), uniapp APP-PLUS, Vue 3 composable ## Global Constraints - 仅适配 APP-PLUS,无 H5/小程序降级 - 2 层图(背景 + 主体),不支持 3+ - 无全息效果、无噪声、无色散、无安全区域 - `LenticularEngine` 原有输出 `renderState` + `layerTransforms` 不破坏 - 文件组织与 `utils/laser-card/` 对仗,放 `utils/lenticular-card/` - WebGL 不可用时自动降级到现有 CSS DOM 路径 - 陀螺仪代码 `useLenticularStudioTilt.js` 不修改 --- ## 文件结构 ### 新增 | 文件 | 职责 | |---|---| | `frontend/utils/lenticular-card/lenticular-webgl-shaders.js` | VERT_SRC + FRAG_SRC(~80 行 GLSL) | | `frontend/utils/lenticular-card/lenticular-webgl-engine.js` | `LenticularWebGLEngine` 类(~200 行) | ### 修改 | 文件 | 改动 | |---|---| | `frontend/utils/lenticular-engine.js` → 移入 `utils/lenticular-card/` | 文件迁移 + `computeRenderState()` 末尾汇出 `engineUniforms` | | `frontend/composables/useLenticularPreview.js` | 追加 `engineUniforms` 返回值 + `registerOnTick` | | `frontend/components/lenticular/LenticularCard.vue` | 双路径渲染 + 引擎生命周期 | ### 不改 `useLenticularStudioTilt.js`、`HolographicCard.vue`、`HolographicEngine`、全部页面层(`lenticular-create/result/thinking.vue`) --- ## Task 1: 建立 `utils/lenticular-card/` 目录并迁移 `lenticular-engine.js` **Files:** - Create: `frontend/utils/lenticular-card/` - Move: `frontend/utils/lenticular-engine.js` → `frontend/utils/lenticular-card/lenticular-engine.js` **Interfaces:** - Consumes: 无(纯文件迁移) - Produces: `lenticular-engine.js` 在 `utils/lenticular-card/` 目录下可被 `import` 引用 - [ ] **Step 1: 创建目录** ```bash mkdir -p frontend/utils/lenticular-card ``` - [ ] **Step 2: 复制文件到新位置** ```bash cp frontend/utils/lenticular-engine.js frontend/utils/lenticular-card/lenticular-engine.js ``` - [ ] **Step 3: 确认所有引用旧路径的地方** 搜索 `@/utils/lenticular-engine.js` 找到所有引用: ```bash grep -rn "lenticular-engine" frontend/ --include="*.js" --include="*.vue" ``` 预期至少命中: - `frontend/composables/useLenticularPreview.js` - `frontend/components/lenticular/LenticularCard.vue` - 可能的测试文件 记录所有路径,后续 task 逐个更新。 - [ ] **Step 4: 删除旧文件** ```bash rm frontend/utils/lenticular-engine.js ``` --- ## Task 2: 编写 lenticular-webgl-shaders.js **Files:** - Create: `frontend/utils/lenticular-card/lenticular-webgl-shaders.js` **Interfaces:** - Consumes: 无 - Produces: `export const VERT_SRC` 和 `export const FRAG_SRC`,供 `LenticularWebGLEngine` 使用 - [ ] **Step 1: 实现 VERT_SRC + FRAG_SRC** ```js /** * 光栅卡 WebGL 着色器 * 管线:UV 偏移 → 条纹交织 → 输出修饰 * 无全息效果 */ export const VERT_SRC = ` attribute vec2 a_position; attribute vec2 a_texCoord; varying vec2 v_texCoord; void main() { v_texCoord = a_texCoord; gl_Position = vec4(a_position * 2.0 - 1.0, 0.0, 1.0); } ` export const FRAG_SRC = ` precision highp float; varying vec2 v_texCoord; uniform sampler2D u_textureA; uniform sampler2D u_textureB; uniform float u_parallax; uniform float u_phase; uniform float u_density; uniform float u_cornerRadius; uniform vec2 u_resolution; uniform float u_dpr; // ---- 圆角 SDF ---- float roundedRectSDF(vec2 p, vec2 halfSize, float r) { vec2 q = abs(p) - halfSize + r; return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; } void main() { // ① UV 偏移 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; // ② 条纹交织 —— 每个像素只来自一张图 float stripe = fract(uv.x * u_density + u_phase); float mask = smoothstep(0.45, 0.55, stripe); vec4 baseColor = texture2D(u_textureA, uv); vec4 frontColor = texture2D(u_textureB, uv); vec4 finalColor = mix(baseColor, frontColor, mask); // ③ 输出修饰 vec2 halfRes = u_resolution * 0.5; vec2 pn = (uv - 0.5) * u_resolution; float cornerRadPx = u_cornerRadius * u_dpr; float sdf = roundedRectSDF(pn, halfRes - cornerRadPx, cornerRadPx); if (sdf > 1.5) discard; // 暗角 vec2 pnNorm = pn / max(halfRes.x, halfRes.y); float vignette = 1.0 - pow(clamp(length(pnNorm) * 1.1, 0.0, 1.0), 2.8) * 0.35; finalColor.rgb *= vignette; // 边缘抗锯齿 float cornerMask = 1.0 - smoothstep(-1.5, 1.5, sdf); float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf); float alpha = cornerMask * edgeAA; finalColor = clamp(finalColor, 0.0, 1.0); gl_FragColor = vec4(finalColor.rgb, alpha); } ` ``` --- ## Task 3: 编写 lenticular-webgl-engine.js **Files:** - Create: `frontend/utils/lenticular-card/lenticular-webgl-engine.js` **Interfaces:** - Consumes: `VERT_SRC`, `FRAG_SRC` from `./lenticular-webgl-shaders.js`; `loadTextureImage` from `@/utils/laser-card/laserPreviewWebgl.js` - Produces: `class LenticularWebGLEngine` 暴露 `init(textureA, textureB)`, `resize(w, h)`, `setUniforms({parallax, phase, density})`, `draw()`, `uploadTexture(index, image)`, `destroy()` - [ ] **Step 1: 实现 LenticularWebGLEngine 类** ```js /** * 光栅卡 WebGL 渲染引擎 * 单 DrawCall,8 uniforms,3 阶段管线 */ import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js' import { VERT_SRC, FRAG_SRC } from './lenticular-webgl-shaders.js' export class LenticularWebGLEngine { constructor(canvas) { this.canvas = canvas this.gl = null this.program = null this.uniformLocs = {} this.textures = [null, null] this._dpr = 1 this._uniforms = { parallax: 0, phase: 0, density: 0.16 } this._initialized = false this._destroyed = false } init(textureSrcA, textureSrcB) { if (this._initialized || this._destroyed) return false const gl = this.canvas.getContext('webgl', { alpha: true, antialias: true, premultipliedAlpha: false, powerPreference: 'high-performance', }) if (!gl) return false this.gl = gl this._dpr = Math.min(window.devicePixelRatio || 1, 2) this._syncSize() // 编译 shader const vs = this._compileShader(gl.VERTEX_SHADER, VERT_SRC) const fs = this._compileShader(gl.FRAGMENT_SHADER, FRAG_SRC) if (!vs || !fs) return false this.program = gl.createProgram() gl.attachShader(this.program, vs) gl.attachShader(this.program, fs) gl.linkProgram(this.program) if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) return false // 缓存 uniform 位置 const names = ['u_textureA', 'u_textureB', 'u_parallax', 'u_phase', 'u_density', 'u_cornerRadius', 'u_resolution', 'u_dpr'] for (const name of names) { this.uniformLocs[name] = gl.getUniformLocation(this.program, name) } // 全屏四边形 const verts = new Float32Array([0,0, 1,0, 0,1, 1,0, 0,1, 1,1]) const buf = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, buf) gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW) const posLoc = gl.getAttribLocation(this.program, 'a_position') const tcLoc = gl.getAttribLocation(this.program, 'a_texCoord') gl.enableVertexAttribArray(posLoc) gl.enableVertexAttribArray(tcLoc) gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0) gl.vertexAttribPointer(tcLoc, 2, gl.FLOAT, false, 16, 8) // 纹理 this.textures[0] = gl.createTexture() this.textures[1] = gl.createTexture() gl.useProgram(this.program) gl.uniform1i(this.uniformLocs.u_textureA, 0) gl.uniform1i(this.uniformLocs.u_textureB, 1) // 加载纹理 if (textureSrcA) this._loadTexture(0, textureSrcA) if (textureSrcB) this._loadTexture(1, textureSrcB) this._initialized = true return true } resize(cssW, cssH) { const w = Math.round(cssW * this._dpr) const h = Math.round(cssH * this._dpr) if (this.canvas.width === w && this.canvas.height === h) return this.canvas.width = w this.canvas.height = h this.gl && this.gl.viewport(0, 0, w, h) } _syncSize() { const rect = this.canvas.getBoundingClientRect() if (rect.width && rect.height) this.resize(rect.width, rect.height) } setUniforms(u) { Object.assign(this._uniforms, u) } draw() { const gl = this.gl if (!gl || !this._initialized) return gl.useProgram(this.program) gl.uniform1f(this.uniformLocs.u_parallax, this._uniforms.parallax) gl.uniform1f(this.uniformLocs.u_phase, this._uniforms.phase) gl.uniform1f(this.uniformLocs.u_density, this._uniforms.density) gl.uniform1f(this.uniformLocs.u_cornerRadius, this._uniforms.cornerRadius || 24) gl.uniform2f(this.uniformLocs.u_resolution, this.canvas.width, this.canvas.height) gl.uniform1f(this.uniformLocs.u_dpr, this._dpr) gl.drawArrays(gl.TRIANGLES, 0, 6) } uploadTexture(index, image) { if (index < 0 || index > 1 || !this.gl) return this._loadTexture(index, image) } async _loadTexture(index, src) { const gl = this.gl const img = typeof src === 'string' ? await loadTextureImage(src) : src if (!img) return gl.activeTexture(gl.TEXTURE0 + index) gl.bindTexture(gl.TEXTURE_2D, this.textures[index]) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) gl.generateMipmap(gl.TEXTURE_2D) } _compileShader(type, src) { const gl = this.gl const s = gl.createShader(type) gl.shaderSource(s, src) gl.compileShader(s) if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { console.error('[LenticularWebGLEngine] shader compile error:', gl.getShaderInfoLog(s)) return null } return s } destroy() { const gl = this.gl if (gl) { if (this.program) gl.deleteProgram(this.program) for (const t of this.textures) { if (t) gl.deleteTexture(t) } } this._destroyed = true this._initialized = false } } ``` --- ## Task 4: lenticular-engine.js 追加 engineUniforms 汇出 **Files:** - Modify: `frontend/utils/lenticular-card/lenticular-engine.js` — `computeRenderState()` 末尾 **Interfaces:** - Consumes: 已有 `this.renderState`、`this.physics`、`this.displayGamma` - Produces: `computeRenderState()` 返回值新增字段 `engineUniforms: { parallax, phase, density }` - [ ] **Step 1: computeRenderState() 末尾追加汇出代码** 找到 `lenticular-engine.js` 中 `computeRenderState()` 的 `return this.renderState` 行,在其之前插入 engineUniforms 计算。注意 `this.renderState.stripePhaseShift` 已经由原有逻辑写入。 ```js // lenticular-engine.js — computeRenderState() 末尾,在 return 之前追加 // ===== WebGL engineUniforms 汇出(不破坏原有输出)===== const parallax = this.displayGamma * (this.physics.tiltSensitivity / 100) * (this.physics.parallaxDepth || 0) * 0.42 const sensitivity = this.physics.tiltSensitivity / 100 const stripePhaseShift = this.renderState.stripePhaseShift != null ? this.renderState.stripePhaseShift : this.displayGamma * (0.38 + 0.52 * sensitivity) const phase = (stripePhaseShift + 1) / 2 // -1~1 → 0~1 const pitchPx = this.renderState.lenticularPitchPx || 16 const density = 50 / pitchPx this.renderState.engineUniforms = { parallax: clamp(parallax, -1, 1), phase: clamp(phase, 0, 1), density: clamp(density, 0.08, 0.3), } return this.renderState ``` 注意:函数顶部已有 `clamp`,无需重复定义。 --- ## Task 5: useLenticularPreview.js 追加 engineUniforms + registerOnTick **Files:** - Modify: `frontend/composables/useLenticularPreview.js` - Modify: 更新 Task 1 中旧路径引用为 `@/utils/lenticular-card/lenticular-engine` **Interfaces:** - Consumes: `LenticularEngine` 的 `computeRenderState()` 返回的 `engineUniforms` - Produces: 新增返回值 `engineUniforms`(ref)、`registerOnTick(fn)` 函数 - [ ] **Step 1: 更新 import 路径** ```js // 旧 import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-engine.js' // 新 import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-card/lenticular-engine.js' ``` - [ ] **Step 2: 新增 registerOnTick 机制** 在 `useLenticularPreview` 函数体内,`let rafId = null` 之后新增: ```js let onTickCallback = null export function registerOnTick(fn) { onTickCallback = typeof fn === 'function' ? fn : null } ``` - [ ] **Step 3: tick() 中调用 onTickCallback** 将 `tick()` 函数从: ```js function tick() { try { const ls = getLayersArray() const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta) applyLayerTransformsFromRenderState(ls, renderState) } catch (e) { console.error('[useLenticularPreview] tick failed', e) } rafId = nextFrame(tick) } ``` 改为: ```js function tick() { try { const ls = getLayersArray() const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta) applyLayerTransformsFromRenderState(ls, renderState) // WebGL onTick if (onTickCallback && renderState.engineUniforms) { onTickCallback(renderState.engineUniforms) } } catch (e) { console.error('[useLenticularPreview] tick failed', e) } rafId = nextFrame(tick) } ``` - [ ] **Step 4: 追加到返回值** ```js // 现有 return 末尾追加 return { // ... 原有返回值 registerOnTick, } ``` --- ## Task 6: 重构 LenticularCard.vue — 双路径渲染 **Files:** - Modify: `frontend/components/lenticular/LenticularCard.vue` - Modify: 更新 import 路径为 `@/utils/lenticular-card/lenticular-engine` **Interfaces:** - Consumes: `useLenticularPreview` 返回的 `layerTransforms` + `registerOnTick` - Consumes: `LenticularWebGLEngine` from `@/utils/lentricular-card/lenticular-webgl-engine` - Consumes: `loadTextureImage` from `@/utils/laser-card/laserPreviewWebgl.js` - [ ] **Step 1: 更新 script setup 头部** ```js