397 lines
12 KiB
JavaScript
397 lines
12 KiB
JavaScript
/**
|
||
* 镭射卡预览专用 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
|
||
}
|