topfans/frontend/utils/laser-card/laserPreviewWebgl.js
2026-06-03 22:19:22 +08:00

518 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 镭射卡预览专用 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 = 红→紫(沿 +tangentwave 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 magnitudesobel-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
}