/** * 镭射卡预览专用 WebGL1 渲染器(全息光栅风格单 Pass)。 * 预览与导出分离:导出仍走 2D Canvas;本模块仅用于页面内实时预览。 * * 依赖:WebGL1 + OES_texture_float_linear 非必须;仅用 RGBA8 纹理。 */ 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 vec4 u_backRect; uniform float u_hasBackdrop; uniform float u_corner; uniform vec3 u_view; uniform float u_vivid; uniform float u_strength; uniform float u_styleHue; uniform sampler2D u_photo; uniform sampler2D u_back; const vec3 LUMA = vec3(0.299, 0.587, 0.114); float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); } float n2(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); float a = hash(i); float b = hash(i + vec2(1.0, 0.0)); float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0)); vec2 u = f * f * (3.0 - 2.0 * f); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } float fbm(vec2 p) { float v = 0.0; float a = 0.52; mat2 m = mat2(1.6, 1.2, -1.2, 1.6); for (int i = 0; i < 6; i++) { v += a * n2(p); p = m * p; a *= 0.5; } return v; } 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; } vec3 hsl2rgb(float h, float s, float l) { float c = (1.0 - abs(2.0 * l - 1.0)) * s; float x = c * (1.0 - abs(mod(h / 60.0, 2.0) - 1.0)); float m = l - 0.5 * c; vec3 rgb; if (h < 60.0) rgb = vec3(c, x, 0.0); else if (h < 120.0) rgb = vec3(x, c, 0.0); else if (h < 180.0) rgb = vec3(0.0, c, x); else if (h < 240.0) rgb = vec3(0.0, x, c); else if (h < 300.0) rgb = vec3(x, 0.0, c); else rgb = vec3(c, 0.0, x); return rgb + m; } 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); } vec3 sampleBackdrop(vec2 uv) { vec2 p = (uv - u_backRect.xy) / u_backRect.zw; if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0) return vec3(0.04, 0.03, 0.06); return texture2D(u_back, p).rgb; } 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; vec3 baseBg = vec3(0.06, 0.045, 0.09); if (u_hasBackdrop > 0.5) { baseBg = sampleBackdrop(uv); } vec4 pc = samplePhoto(uv); float photoMask = photoInside(uv); vec3 photoRgb = pc.rgb; float eps = 1.2 / u_resolution.x; vec4 px1 = samplePhoto(uv + vec2(eps, 0.0)); vec4 px2 = samplePhoto(uv + vec2(0.0, eps)); float l0 = dot(photoRgb, LUMA); float l1 = dot(px1.rgb, LUMA); float l2 = dot(px2.rgb, LUMA); float edge = clamp(abs(l1 - l0) + abs(l2 - l0), 0.0, 1.0) * 3.5; vec2 pF = uv * vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0) * 3.4; float f = fbm(pF + u_view.xy * 0.15); float f2 = fbm(pF * 1.7 + vec2(13.1, 9.7) + u_view.z * 0.08); vec2 g = vec2(fbm(pF + vec2(1.7, 0.0)) - fbm(pF - vec2(1.7, 0.0)), fbm(pF + vec2(0.0, 1.7)) - fbm(pF - vec2(0.0, 1.7))); float ang = atan(g.y, g.x) + u_view.x * 1.15 + u_view.y * 0.9 + f * 6.28318 * 0.35; float film = sin(dot(uv * u_resolution.xy * 0.02, vec2(1.0, 1.73)) * 3.14159 + u_time * 0.0012 + u_view.z); float hue = fract(ang / 6.28318 + f2 * 0.22 + u_styleHue / 360.0 + u_time * 0.00008 + film * 0.08); float sat = 0.72 + u_vivid * 0.28; float lit = 0.48 + f * 0.22; vec3 holo = hsl2rgb(hue * 360.0, sat, lit); float carrier = smoothstep(0.15, 0.95, pow(f, 0.85)) * (0.35 + edge * 0.85); carrier = clamp(carrier * u_strength * (0.55 + u_vivid * 0.45), 0.0, 1.35); vec3 holoRgb = holo * (0.55 + 0.45 * sin(u_time * 0.001 + f * 10.0)); float sp = step(0.988, hash(floor(uv * u_resolution * 1.2) + floor(u_time * 0.02))); vec3 sparkle = vec3(1.0) * sp * 0.55; vec3 base = mix(baseBg, photoRgb, photoMask); vec3 ir = holoRgb * carrier; vec3 screenBlend = 1.0 - (1.0 - base) * (1.0 - clamp(ir * 1.1, 0.0, 1.0)); vec3 col = mix(base, screenBlend, clamp(carrier * 0.92, 0.0, 1.0)); col += sparkle * carrier; float ca = 0.0018 * (0.5 + u_vivid); vec2 o = vec2(ca * sin(u_view.z), ca * cos(u_view.z * 0.7)); vec3 cr = hsl2rgb(fract(hue + 0.02) * 360.0, sat * 0.9, lit * 1.05); vec3 cb = hsl2rgb(fract(hue - 0.03) * 360.0, sat * 0.95, lit * 0.95); col.r = mix(col.r, cr.r, 0.12 * carrier); col.b = mix(col.b, cb.b, 0.12 * carrier); float vign = 1.0 - pow(length(pN) / 0.72, 2.2) * 0.12; 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_backRect: gl.getUniformLocation(program, 'u_backRect'), u_hasBackdrop: gl.getUniformLocation(program, 'u_hasBackdrop'), u_corner: gl.getUniformLocation(program, 'u_corner'), u_view: gl.getUniformLocation(program, 'u_view'), u_vivid: gl.getUniformLocation(program, 'u_vivid'), u_strength: gl.getUniformLocation(program, 'u_strength'), u_styleHue: gl.getUniformLocation(program, 'u_styleHue'), u_photo: gl.getUniformLocation(program, 'u_photo'), u_back: gl.getUniformLocation(program, 'u_back') } 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() const texBack = gl.createTexture() let photoReady = false let backReady = 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) gl.deleteTexture(texBack) }, /** @param {HTMLImageElement|ImageBitmap|HTMLCanvasElement} img */ uploadPhoto(img) { if (!img || !img.width) return orthoTexImage(gl, texPhoto, img) photoReady = true }, /** @param {HTMLImageElement|ImageBitmap|HTMLCanvasElement|null} img */ uploadBackdrop(img) { if (!img || !img.width) { backReady = false return } orthoTexImage(gl, texBack, img) backReady = true }, hasPhoto() { return photoReady }, hasBackdrop() { return backReady }, /** * @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 let bx = 0 let nyb = 0 let nbw = 1 let nbh = 1 let hasB = !!(u.hasBackdrop && backReady && u.backRect) if (hasB) { const br = u.backRect bx = br.dx / cw nyb = br.dy / ch nbw = br.dw / cw nbh = br.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 gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight) gl.disable(gl.DEPTH_TEST) gl.disable(gl.STENCIL_TEST) gl.clearColor(0.04, 0.03, 0.06, 1) 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.uniform4f(loc.u_backRect, bx, nyb, nbw, nbh) gl.uniform1f(loc.u_hasBackdrop, hasB ? 1 : 0) gl.uniform1f(loc.u_corner, cornerUv) gl.uniform3f(loc.u_view, vx, vy, vz) gl.uniform1f(loc.u_vivid, typeof u.vivid === 'number' ? u.vivid : 1) gl.uniform1f(loc.u_strength, typeof u.strength === 'number' ? u.strength : 0.82) gl.uniform1f(loc.u_styleHue, typeof u.styleHue === 'number' ? u.styleHue : 0) bindUnit(0, texPhoto, loc.u_photo) bindUnit(1, texBack, loc.u_back) 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 }