/** * 镭射卡预览专用 WebGL1 渲染器(全息光栅风格单 Pass)。 * 预览与导出分离:导出仍走 2D Canvas;本模块仅用于页面内实时预览。 * * 依赖:WebGL1 + OES_texture_float_linear 非必须;仅用 RGBA8 纹理。 */ import { hexToRgb } from './laserGrating.js' const VERT_SRC = ` attribute vec2 a_uv; varying vec2 v_uv; void main() { v_uv = a_uv; gl_Position = vec4(a_uv * 2.0 - 1.0, 0.0, 1.0); } ` const FRAG_SRC = ` precision highp float; varying vec2 v_uv; uniform vec2 u_resolution; uniform float u_time; uniform vec4 u_photoRect; uniform float u_corner; uniform vec3 u_view; uniform vec3 u_lightDir; uniform float u_styleHue; uniform float u_frostIntensity; uniform float u_flowSpeed; uniform float u_stripeAngle; uniform float u_stripeIntensity; uniform float u_backdropToneR; uniform float u_backdropToneG; uniform float u_backdropToneB; uniform float u_foilCoverage; uniform vec4 u_personEdge; uniform sampler2D u_photo; // ============================================================ // 物理波长→RGB (spectral_zucconi6) // ============================================================ vec3 bump3y(vec3 x, vec3 yoffset) { vec3 y = 1.0 - x * x; y = clamp(y - yoffset, 0.0, 1.0); return y; } vec3 spectral_zucconi6(float w) { float x = clamp((w - 400.0) / 300.0, 0.0, 1.0); const vec3 c1 = vec3(3.54585104, 2.93225262, 2.41593945); const vec3 x1 = vec3(0.69549072, 0.49228336, 0.27699880); const vec3 y1 = vec3(0.02312639, 0.15225084, 0.52607955); const vec3 c2 = vec3(3.90307140, 3.21182957, 3.96587128); const vec3 x2 = vec3(0.11748627, 0.86755042, 0.66077860); const vec3 y2 = vec3(0.84897130, 0.88445281, 0.73949448); return bump3y(c1 * (x - x1), y1) + bump3y(c2 * (x - x2), y2); } float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } float roundedBox(vec2 p, vec2 b, float r) { vec2 q = abs(p) - b + r; return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; } vec4 samplePhoto(vec2 uv) { vec2 pRaw = (uv - u_photoRect.xy) / u_photoRect.zw; vec2 p = clamp(pRaw, 0.0, 1.0); return texture2D(u_photo, p); } float photoInside(vec2 uv) { vec2 pRaw = (uv - u_photoRect.xy) / u_photoRect.zw; return step(0.0, pRaw.x) * step(pRaw.x, 1.0) * step(0.0, pRaw.y) * step(pRaw.y, 1.0); } // ============================================================ // 常驻双彩虹色带(实体镭射卡在静态时刻也可见) // 沿光栅法线方向分布 2 个高斯峰 + 时间微动 // ============================================================ vec3 computeSheenBand(vec2 uv, vec3 viewDir, vec3 lightDir, float bandAngle, float intensity, float timeMs) { float ang = bandAngle * 0.01745329252; vec2 normal = vec2(-sin(ang), cos(ang)); float proj = dot(uv - 0.5, normal); // 时间流动:色带在法线方向上缓慢漂移 float flow = timeMs * 0.00018 * max(u_flowSpeed, 0.15); // 双高斯:2 条固定分布的色带(center1=-0.18, center2=+0.18) // 每条带宽 ~0.18(占整张卡对角线比例) float c1 = -0.18; float c2 = +0.18; float dist1 = abs(proj - c1); float dist2 = abs(proj - c2); float band1 = exp(-(dist1 * dist1) / 0.022); // 0.022 ~ 1/45 float band2 = exp(-(dist2 * dist2) / 0.022); // 沿切线方向,每条色带内部波长 400-700nm 渐变 vec2 tangent = vec2(cos(ang), sin(ang)); float along = dot(uv - 0.5, tangent); // 每条色带颜色:wave 1 = 红→紫(沿 +tangent),wave 2 = 紫→红(沿 -tangent) // 加上 flow 让色带略动 float hue1 = fract(along * 0.6 + flow * 0.5); float hue2 = fract(-along * 0.6 + flow * 0.5 + 0.5); // wavelength hue1=0 → 700nm(紫), hue1=1 → 400nm(红) float w1 = mix(700.0, 400.0, hue1); float w2 = mix(400.0, 700.0, hue2); vec3 c1col = spectral_zucconi6(w1); vec3 c2col = spectral_zucconi6(w2); return (c1col * band1 + c2col * band2) * intensity; } // ============================================================ // 人像边缘彩虹圈:沿抠图 alpha 边界绘制彩虹光晕 // 这是「实体镭射卡最显眼的视觉信号」 // 思路:pc.a 边缘的 gradient magnitude(sobel-like)就是边界强度 // ============================================================ vec3 computePersonEdgeHalo(vec2 uv, vec3 lightDir) { // 3x3 邻域采样 alpha vec2 texel = vec2(1.0) / u_resolution; float aC = samplePhoto(uv).a; float aL = samplePhoto(uv - vec2(texel.x, 0.0)).a; float aR = samplePhoto(uv + vec2(texel.x, 0.0)).a; float aT = samplePhoto(uv - vec2(0.0, texel.y)).a; float aB = samplePhoto(uv + vec2(0.0, texel.y)).a; // alpha 梯度(人像边缘 = 大梯度) float gx = (aR - aL) * 0.5; float gy = (aB - aT) * 0.5; float edge = sqrt(gx * gx + gy * gy); // 抑制图外的假边缘:图内 alpha > 0.5 区域 OR 图外 float inside = photoInside(uv); // 在 alpha 边界外侧 ~1-2 px 处也应有 halo,所以向外延伸 float pcC = samplePhoto(uv).a * inside; // 让 halo 出现在 alpha ~0.1~0.6 范围(边缘 + 外溢 1-2px) float haloBand = smoothstep(0.05, 0.4, aC) * (1.0 - smoothstep(0.55, 0.95, aC)); float haloEdge = edge * 8.0 + haloBand * 0.6; // 沿边界法线方向映射彩虹色相 // 边界法线 ≈ (gx, gy)(指向 alpha 增大方向,即从外指向人像) float ang = atan(gy, gx); float hue = fract(ang / 6.28318 + dot(lightDir.xy, vec2(0.3, 0.5)) * 0.2 + 0.5); float wavelength = mix(400.0, 700.0, hue); vec3 haloColor = spectral_zucconi6(wavelength); return haloColor * haloEdge * 1.4; } // ============================================================ // 银粉星点散点层:高频亮点模拟金属膜的细反光 // ============================================================ vec3 computeSilverSparkle(vec2 uv, float density) { // 三层不同频率的 hash 网格:避免规律性 float total = 0.0; vec3 totalColor = vec3(0.0); // Layer A:密集细点(每 8x8 像素 1 个) vec2 gA = floor(uv * u_resolution / 8.0); float hA = hash(gA + 13.0); if (hA > 0.985) { vec2 cellUV = fract(uv * u_resolution / 8.0) - 0.5; float dA = length(cellUV); float starA = smoothstep(0.45, 0.0, dA) * 0.8; total = max(total, starA); // 亮点颜色 = 接近白 + 微紫/微蓝偏移 totalColor += vec3(0.95, 0.96, 1.0) * starA; } // Layer B:中等亮点(每 16x16 像素 1 个) vec2 gB = floor(uv * u_resolution / 16.0); float hB = hash(gB + 271.0); if (hB > 0.992) { vec2 cellUV = fract(uv * u_resolution / 16.0) - 0.5; float dB = length(cellUV); float starB = smoothstep(0.42, 0.0, dB) * 0.95; // 偶尔有彩色星点(彩虹光) float colorRoll = hash(gB + 777.0); vec3 starCol = colorRoll > 0.7 ? spectral_zucconi6(mix(420.0, 680.0, hash(gB + 333.0))) : vec3(1.0, 1.0, 1.0); if (starB > total) { total = starB; totalColor = starCol * starB; } else { totalColor += starCol * starB * 0.6; } } return totalColor * density; } // ============================================================ // 金属反光梯度:对角方向的高光带,让卡面有"银的反射"质感 // ============================================================ float computeMetallicSheen(vec2 uv, float bandAngle) { float ang = bandAngle * 0.01745329252; vec2 normal = vec2(-sin(ang), cos(ang)); float proj = dot(uv - 0.5, normal); // 一条柔和的对角反光(中心 0.0, 宽度 0.4) float dist = abs(proj); float main = exp(-(dist * dist) / 0.10); // 加上次级反光(小一号,-0.3 处) float side = exp(-((dist - 0.32) * (dist - 0.32)) / 0.06) * 0.5; return main + side; } void main() { vec2 uv = v_uv; vec2 pN = (uv - 0.5) * vec2(u_resolution.x / u_resolution.y, 1.0); float rb = roundedBox(pN, vec2(0.5 * u_resolution.x / u_resolution.y, 0.5), u_corner); if (rb > 0.002) discard; // === 1. 金属底色 === vec3 baseBg = vec3(u_backdropToneR, u_backdropToneG, u_backdropToneB); // 把纯色底加一个非常轻的"金属反光梯度"(让白底变银底) float metalShine = computeMetallicSheen(uv, u_stripeAngle); vec3 silverBase = baseBg + vec3(0.20, 0.18, 0.16) * metalShine * 0.35; silverBase = clamp(silverBase, 0.0, 1.0); // === 2. 人像合成 === vec4 pc = samplePhoto(uv); float photoMask = photoInside(uv); float personAlpha = pc.a * photoMask; // 人像用原图,背板用银底 vec3 metalWithPhoto = mix(silverBase, pc.rgb, personAlpha); // === 3. 视角 === vec3 viewDir = normalize(vec3(u_view.x, u_view.y, 0.7)); vec3 lightDir = normalize(vec3(0.8, 0.6, 0.5)); // === 4. 彩虹色带(双条常驻)=== vec3 sheenBand = computeSheenBand( uv, viewDir, lightDir, u_stripeAngle, u_stripeIntensity, u_time ); // === 5. 人像边缘彩虹圈(P0 核心)=== vec3 personHalo = computePersonEdgeHalo(uv, lightDir); // === 6. 银粉星点 === float sparkleDensity = mix(0.4, 0.9, clamp(u_frostIntensity / 100.0, 0.0, 1.0)); vec3 sparkle = computeSilverSparkle(uv, sparkleDensity); // === 7. 合成:背景 foil + 边缘 halo + 星点 === // foil 覆盖:背景 ~0.85,人物上 0.3(让人像也有轻微反光) float foilWeight = mix(0.85, 0.30, personAlpha); // screen 混合色带和 halo vec3 sheenTotal = clamp(sheenBand, 0.0, 1.0); vec3 screenBlend = 1.0 - (1.0 - metalWithPhoto) * (1.0 - sheenTotal); vec3 col = mix(metalWithPhoto, screenBlend, foilWeight); // 边缘彩虹圈:additive(不挡人物) col += personHalo * (1.0 - personAlpha * 0.5) * 0.7; // 银粉星点:additive col += sparkle * 0.85; // === 8. 磨砂(保留非常轻的银粉底纹)=== float frostAmt = clamp(u_frostIntensity / 100.0, 0.0, 1.0) * 0.4; float frost = hash(floor(uv * u_resolution * 2.4)); col = mix(col, col * (0.96 + frost * 0.08), frostAmt * 0.20); // === 9. 极弱 vignette === float vign = 1.0 - pow(length(pN) / 0.72, 2.2) * 0.06; col *= vign; gl_FragColor = vec4(col, 1.0); } ` function compileShader(gl, type, src) { const sh = gl.createShader(type) gl.shaderSource(sh, src) gl.compileShader(sh) if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { const err = gl.getShaderInfoLog(sh) gl.deleteShader(sh) throw new Error(err || 'shader compile') } return sh } function createProgram(gl, vs, fs) { const prog = gl.createProgram() gl.attachShader(prog, compileShader(gl, gl.VERTEX_SHADER, vs)) gl.attachShader(prog, compileShader(gl, gl.FRAGMENT_SHADER, fs)) gl.linkProgram(prog) if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { const err = gl.getProgramInfoLog(prog) gl.deleteProgram(prog) throw new Error(err || 'program link') } return prog } function orthoTexImage(gl, tex, source) { gl.bindTexture(gl.TEXTURE_2D, tex) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source) 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.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.bindTexture(gl.TEXTURE_2D, null) } /** * @param {WebGLRenderingContext} gl * @param {{ width: number, height: number, canvas?: any }} opts */ export function createLaserPreviewWebgl(gl, opts) { const w = Math.max(2, Math.floor(opts.width)) const h = Math.max(2, Math.floor(opts.height)) const program = createProgram(gl, VERT_SRC, FRAG_SRC) const loc = { a_uv: gl.getAttribLocation(program, 'a_uv'), u_resolution: gl.getUniformLocation(program, 'u_resolution'), u_time: gl.getUniformLocation(program, 'u_time'), u_photoRect: gl.getUniformLocation(program, 'u_photoRect'), u_corner: gl.getUniformLocation(program, 'u_corner'), u_view: gl.getUniformLocation(program, 'u_view'), u_lightDir: gl.getUniformLocation(program, 'u_lightDir'), u_styleHue: gl.getUniformLocation(program, 'u_styleHue'), u_frostIntensity: gl.getUniformLocation(program, 'u_frostIntensity'), u_flowSpeed: gl.getUniformLocation(program, 'u_flowSpeed'), u_stripeAngle: gl.getUniformLocation(program, 'u_stripeAngle'), u_stripeIntensity: gl.getUniformLocation(program, 'u_stripeIntensity'), u_backdropToneR: gl.getUniformLocation(program, 'u_backdropToneR'), u_backdropToneG: gl.getUniformLocation(program, 'u_backdropToneG'), u_backdropToneB: gl.getUniformLocation(program, 'u_backdropToneB'), u_foilCoverage: gl.getUniformLocation(program, 'u_foilCoverage'), u_personEdge: gl.getUniformLocation(program, 'u_personEdge'), u_photo: gl.getUniformLocation(program, 'u_photo'), } const buf = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, buf) const quad = new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]) gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW) const texPhoto = gl.createTexture() let photoReady = false function bindUnit(unit, tex, loc) { gl.activeTexture(gl.TEXTURE0 + unit) gl.bindTexture(gl.TEXTURE_2D, tex) gl.uniform1i(loc, unit) } return { gl, w, h, program, /** 释放 GPU 资源 */ destroy() { gl.deleteProgram(program) gl.deleteBuffer(buf) gl.deleteTexture(texPhoto) }, /** @param {HTMLImageElement|ImageBitmap|HTMLCanvasElement} img */ uploadPhoto(img) { if (!img || !img.width) return orthoTexImage(gl, texPhoto, img) photoReady = true }, /** @deprecated 预览改用纯色底,保留空实现兼容旧调用 */ uploadBackdrop() {}, hasPhoto() { return photoReady }, hasBackdrop() { return false }, /** * @param {number} timeMs * @param {object} u * @param {{ dx: number, dy: number, dw: number, dh: number }} u.photoRect 像素 cover 矩形 * @param {object} [u.backRect] * @param {boolean} [u.hasBackdrop] * @param {number} [u.cornerRadiusPx] * @param {number} [u.vivid] 0.75~1.35 * @param {number} [u.strength] 0.35~1.2 * @param {number} [u.styleHue] beam 风格色相偏置 0~360 */ render(timeMs, u) { if (!photoReady) return const pr = u.photoRect const cw = u.canvasW || w const ch = u.canvasH || h const nx = pr.dx / cw const ny = pr.dy / ch const nw = pr.dw / cw const nh = pr.dh / ch const rPx = typeof u.cornerRadiusPx === 'number' ? u.cornerRadiusPx : Math.min(32, cw * 0.065, ch * 0.048) const cornerUv = rPx / Math.min(cw, ch) const t = (timeMs || 0) * 0.001 const speed = typeof u.animSpeed === 'number' ? u.animSpeed : 1 const vx = Math.sin(t * 0.42 * speed) * 0.7 + Math.sin(t * 0.17 * speed) * 0.35 const vy = Math.cos(t * 0.31 * speed) * 0.55 + Math.cos(t * 0.21 * speed) * 0.28 const vz = t * 0.55 * speed const cfg = u.gratingConfig || {} const [br, bg, bb] = hexToRgb(cfg.backdropTone) gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight) gl.disable(gl.DEPTH_TEST) gl.disable(gl.STENCIL_TEST) gl.clearColor(0, 0, 0, 0) gl.clear(gl.COLOR_BUFFER_BIT) gl.useProgram(program) gl.bindBuffer(gl.ARRAY_BUFFER, buf) gl.enableVertexAttribArray(loc.a_uv) gl.vertexAttribPointer(loc.a_uv, 2, gl.FLOAT, false, 0, 0) gl.uniform2f(loc.u_resolution, cw, ch) gl.uniform1f(loc.u_time, timeMs || 0) gl.uniform4f(loc.u_photoRect, nx, ny, nw, nh) gl.uniform1f(loc.u_corner, cornerUv) gl.uniform3f(loc.u_view, vx, vy, vz) gl.uniform3f(loc.u_lightDir, 0.8, 0.6, 0.5) gl.uniform1f(loc.u_styleHue, typeof u.styleHue === 'number' ? u.styleHue : 0) gl.uniform1f(loc.u_frostIntensity, typeof u.frostIntensity === 'number' ? u.frostIntensity : 8) gl.uniform1f(loc.u_flowSpeed, typeof u.flowSpeed === 'number' ? u.flowSpeed : 0.4) gl.uniform1f(loc.u_stripeAngle, cfg.stripeAngle ?? 135) gl.uniform1f(loc.u_stripeIntensity, cfg.stripeIntensity ?? 0.32) gl.uniform1f(loc.u_backdropToneR, br / 255) gl.uniform1f(loc.u_backdropToneG, bg / 255) gl.uniform1f(loc.u_backdropToneB, bb / 255) gl.uniform1f(loc.u_foilCoverage, typeof cfg.foilCoverage === 'number' ? cfg.foilCoverage : 0.7) // 人物边缘参数(目前 shader 用 alpha 梯度自动检测,但保留接口供扩展) const pex = typeof u.personEdgeX === 'number' ? u.personEdgeX : 0.5 const pey = typeof u.personEdgeY === 'number' ? u.personEdgeY : 0.5 const peiR = typeof u.personEdgeInner === 'number' ? u.personEdgeInner : 0.28 const peoR = typeof u.personEdgeOuter === 'number' ? u.personEdgeOuter : 0.42 gl.uniform4f(loc.u_personEdge, pex, pey, peiR, peoR) bindUnit(0, texPhoto, loc.u_photo) gl.drawArrays(gl.TRIANGLES, 0, 6) gl.bindTexture(gl.TEXTURE_2D, null) } } } /** * 从本地路径或 URL 加载图片;App/H5 用 Image,小程序等用 canvas.createImage。 * @param {string} src * @param {any} [canvasForCreateImage] type=webgl 的 canvas node */ export function loadTextureImage(src, canvasForCreateImage) { return new Promise((resolve, reject) => { if (!src) { reject(new Error('empty src')) return } const c = canvasForCreateImage if (c && typeof c.createImage === 'function') { try { const img = c.createImage() img.onload = () => resolve(img) img.onerror = () => reject(new Error('createImage load')) img.src = src return } catch (e2) { /* fall through */ } } if (typeof Image !== 'undefined') { try { const img = new Image() try { img.crossOrigin = 'anonymous' } catch (e0) { /* noop */ } img.onload = () => resolve(img) img.onerror = () => reject(new Error('image load')) img.src = src return } catch (e) { reject(e) return } } reject(new Error('no image loader')) }) } /** 各 beamEffect 对应色相偏置(度),与 2D 视觉大致同系 */ export const BEAM_STYLE_HUE = { white: 0, prism: 18, aurora: 42, sunset: 6, neon: 72 }