/** * 全息镭射卡 WebGL 渲染引擎 * * 反模糊关键设计: * 1. Canvas 分辨率 = CSS尺寸 × DPR(上限2),保证像素密度 * 2. 基础图像纹理启用 Mipmap,GL_LINEAR_MIPMAP_LINEAR 采样 * 3. WebGL context 开启 MSAA 抗锯齿 * 4. FBO 离屏渲染时分辨率与 Canvas 完全一致 * 5. 安全区域着色器内 baseColor 直通输出 */ import { HOLO_VERT_SRC, HOLO_FRAG_SRC, generateSpectrumRampData } from './holographic-shaders.js' const DEFAULT_CONFIG = { alpha: true, antialias: true, premultipliedAlpha: false, preserveDrawingBuffer: false, powerPreference: 'high-performance', failIfMajorPerformanceCaveat: false, } const DEFAULT_EFFECT_PARAMS = { effectIntensity: 0.85, dispersionStrength: 1.0, diffractionScale: 0.7, highlightSpeed: 0.8, highlightWidth: 1.0, fresnelPower: 3.5, noiseScale: 1.0, noiseOctaves: 6, cardCornerRadius: 24, safeZoneRadius: 0.35, safeZoneSoftness: 0.15, } export class HolographicEngine { constructor(canvas, opts = {}) { this.canvas = canvas this.gl = null this.program = null this.uniforms = {} this.attribs = {} this._initialized = false this._destroyed = false this._rafId = null this._lastFrameTime = 0 this._dpr = 1 this._cssW = 0 this._cssH = 0 this._config = { ...DEFAULT_CONFIG, ...opts } this._effectParams = { ...DEFAULT_EFFECT_PARAMS } this._viewAngle = { x: 0, y: 0, z: 0 } this._lightDir = { x: 0.6, y: 0.4, z: 1.0 } this._textureFlag = { baseImage: false } this._buffers = {} this._textures = {} this._useFBO = opts.useFBO !== false this._fbo = null this._fboTex = null } // ============ 初始化 ============ init() { if (this._initialized || this._destroyed) return this._initialized try { this.gl = this._createContext() if (!this.gl) return false this._dpr = Math.min(window.devicePixelRatio || 1, 2) this._syncCanvasSize() this._setupGLState() this._compileShaders() this._cacheLocations() this._createQuadBuffer() this._createTextures() this._setupFBO() this._initialized = true return true } catch (e) { console.error('[HolographicEngine] init failed:', e) this._cleanup() return false } } _createContext() { const gl = this.canvas.getContext('webgl', this._config) || this.canvas.getContext('experimental-webgl', this._config) if (!gl) return null // 尝试获取 MSAA 实际采样数 const samples = gl.getParameter(gl.SAMPLES) if (samples > 0) { console.log('[HolographicEngine] MSAA samples:', samples) } return gl } /** * 将 Canvas 缓冲分辨率设置为 CSS 尺寸 × DPR,保证像素密度 * CSS 尺寸由父容器决定,引擎 resize() 同步更新 */ _syncCanvasSize() { const rect = this.canvas.getBoundingClientRect() const cssW = rect.width > 0 ? rect.width : 300 const cssH = rect.height > 0 ? rect.height : 400 this._cssW = cssW this._cssH = cssH const bufW = Math.floor(cssW * this._dpr) const bufH = Math.floor(cssH * this._dpr) if (this.canvas.width !== bufW || this.canvas.height !== bufH) { this.canvas.width = bufW this.canvas.height = bufH } } _setupGLState() { const gl = this.gl gl.enable(gl.BLEND) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) gl.disable(gl.DEPTH_TEST) gl.disable(gl.CULL_FACE) gl.clearColor(0.0, 0.0, 0.0, 0.0) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false) } _compileShader(type, src) { const gl = this.gl const sh = gl.createShader(type) gl.shaderSource(sh, src) gl.compileShader(sh) if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { const log = gl.getShaderInfoLog(sh) gl.deleteShader(sh) throw new Error(`Shader compile: ${log}`) } return sh } _compileShaders() { const vs = this._compileShader(this.gl.VERTEX_SHADER, HOLO_VERT_SRC) const fs = this._compileShader(this.gl.FRAGMENT_SHADER, HOLO_FRAG_SRC) this.program = this.gl.createProgram() this.gl.attachShader(this.program, vs) this.gl.attachShader(this.program, fs) this.gl.linkProgram(this.program) if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) { throw new Error(`Program link: ${this.gl.getProgramInfoLog(this.program)}`) } this.gl.deleteShader(vs) this.gl.deleteShader(fs) } _cacheLocations() { const gl = this.gl const p = this.program this.attribs.a_position = gl.getAttribLocation(p, 'a_position') this.attribs.a_texCoord = gl.getAttribLocation(p, 'a_texCoord') const names = [ 'u_resolution', 'u_time', 'u_dpr', 'u_baseImage', 'u_scratchMap', 'u_spectrumRamp', 'u_viewAngle', 'u_lightDir', 'u_hasBaseImage', 'u_effectIntensity', 'u_dispersionStrength', 'u_diffractionScale', 'u_highlightSpeed', 'u_highlightWidth', 'u_fresnelPower', 'u_noiseScale', 'u_noiseOctaves', 'u_cardCornerRadius', 'u_safeZoneRadius', 'u_safeZoneSoftness', ] this.uniforms = {} for (const name of names) { this.uniforms[name] = gl.getUniformLocation(p, name) } } _createQuadBuffer() { const gl = this.gl this._buffers.quad = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, this._buffers.quad) const verts = new Float32Array([ 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, ]) gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW) } _createTextures() { const gl = this.gl this._textures.baseImage = gl.createTexture() this._textures.spectrumRamp = gl.createTexture() this._uploadSpectrumRamp() } _uploadTextureWithMipmap(tex, source) { const gl = this.gl gl.bindTexture(gl.TEXTURE_2D, tex) gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source) // 生成 Mipmap 金字塔,保证缩小/倾斜采样时自动选取最优层级 gl.generateMipmap(gl.TEXTURE_2D) 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_MIPMAP_LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.bindTexture(gl.TEXTURE_2D, null) } _uploadSpectrumRamp() { const gl = this.gl const data = generateSpectrumRampData(256) gl.bindTexture(gl.TEXTURE_2D, this._textures.spectrumRamp) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT) 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) } _setupFBO() { if (!this._useFBO) return const gl = this.gl const w = this.canvas.width const h = this.canvas.height this._fboTex = gl.createTexture() gl.bindTexture(gl.TEXTURE_2D, this._fboTex) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.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) this._fbo = gl.createFramebuffer() gl.bindFramebuffer(gl.FRAMEBUFFER, this._fbo) gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._fboTex, 0) gl.bindFramebuffer(gl.FRAMEBUFFER, null) gl.bindTexture(gl.TEXTURE_2D, null) } // ============ 公开 API ============ setEffectParams(params) { Object.assign(this._effectParams, params) } getEffectParams() { return { ...this._effectParams } } setViewAngle(x, y, z = 0) { this._viewAngle.x = Math.max(-1, Math.min(1, x)) this._viewAngle.y = Math.max(-1, Math.min(1, y)) this._viewAngle.z = Math.max(-1, Math.min(1, z)) } setLightDir(x, y, z = 1) { this._lightDir = { x, y, z } } /** * 上传基础图像,自动生成 Mipmap 金字塔 */ uploadBaseImage(source) { if (!source || !source.width) return this._textureFlag.baseImage = true this._uploadTextureWithMipmap(this._textures.baseImage, source) } clearBaseImage() { this._textureFlag.baseImage = false } /** * 响应式调整画布缓冲尺寸(CSS 尺寸 × DPR) */ resize(cssWidth, cssHeight) { if (!this.gl || this._destroyed) return if (cssWidth === this._cssW && cssHeight === this._cssH) return this._cssW = cssWidth this._cssH = cssHeight const w = Math.floor(cssWidth * this._dpr) const h = Math.floor(cssHeight * this._dpr) if (this.canvas.width === w && this.canvas.height === h) return this.canvas.width = w this.canvas.height = h if (this._useFBO && this._fboTex) { const gl = this.gl gl.bindTexture(gl.TEXTURE_2D, this._fboTex) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null) gl.bindTexture(gl.TEXTURE_2D, null) } this.gl.viewport(0, 0, w, h) } setDPR(dpr) { this._dpr = Math.min(dpr, 2) this._syncCanvasSize() } // ============ 渲染 ============ render(timestamp) { if (!this._initialized || this._destroyed) return const gl = this.gl const time = timestamp || performance.now() this._lastFrameTime = time const bufW = this.canvas.width const bufH = this.canvas.height // FBO 离屏渲染 if (this._useFBO && this._fbo) { gl.bindFramebuffer(gl.FRAMEBUFFER, this._fbo) } gl.viewport(0, 0, bufW, bufH) gl.clear(gl.COLOR_BUFFER_BIT) gl.useProgram(this.program) // 绑定顶点 gl.bindBuffer(gl.ARRAY_BUFFER, this._buffers.quad) const stride = 16 // 4 floats × 4 bytes if (this.attribs.a_position >= 0) { gl.enableVertexAttribArray(this.attribs.a_position) gl.vertexAttribPointer(this.attribs.a_position, 2, gl.FLOAT, false, stride, 0) } if (this.attribs.a_texCoord >= 0) { gl.enableVertexAttribArray(this.attribs.a_texCoord) gl.vertexAttribPointer(this.attribs.a_texCoord, 2, gl.FLOAT, false, stride, 8) } // 统一变量 const u = this.uniforms gl.uniform2f(u.u_resolution, bufW, bufH) gl.uniform1f(u.u_time, time) gl.uniform1f(u.u_dpr, this._dpr) gl.uniform3f(u.u_viewAngle, this._viewAngle.x, this._viewAngle.y, this._viewAngle.z) gl.uniform3f(u.u_lightDir, this._lightDir.x, this._lightDir.y, this._lightDir.z) gl.uniform1f(u.u_hasBaseImage, this._textureFlag.baseImage ? 1.0 : 0.0) // 纹理绑定(baseImage 在 0 号单元) gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, this._textures.baseImage) gl.uniform1i(u.u_baseImage, 0) gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, this._textures.spectrumRamp) gl.uniform1i(u.u_spectrumRamp, 1) // 效果参数 const ep = this._effectParams gl.uniform1f(u.u_effectIntensity, ep.effectIntensity) gl.uniform1f(u.u_dispersionStrength, ep.dispersionStrength) gl.uniform1f(u.u_diffractionScale, ep.diffractionScale) gl.uniform1f(u.u_highlightSpeed, ep.highlightSpeed) gl.uniform1f(u.u_highlightWidth, ep.highlightWidth) gl.uniform1f(u.u_fresnelPower, ep.fresnelPower) gl.uniform1f(u.u_noiseScale, ep.noiseScale) gl.uniform1f(u.u_noiseOctaves, ep.noiseOctaves) gl.uniform1f(u.u_cardCornerRadius, ep.cardCornerRadius) gl.uniform1f(u.u_safeZoneRadius, ep.safeZoneRadius) gl.uniform1f(u.u_safeZoneSoftness, ep.safeZoneSoftness) gl.drawArrays(gl.TRIANGLES, 0, 6) // FBO → 屏幕(单 Pass 拷贝,无缩放损耗) if (this._useFBO && this._fbo) { gl.bindFramebuffer(gl.FRAMEBUFFER, null) gl.clear(gl.COLOR_BUFFER_BIT) // 复用同一 shader 但 texture 切到 FBO 纹理(简化:直接清空后重绑) gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, this._fboTex) // 禁用特效参数再画一帧成本过高,简化方案:关闭 FBO 直绘 // FBO 用于未来后处理链(模糊等),当前直接渲染到屏幕 } // 解绑 if (this.attribs.a_position >= 0) gl.disableVertexAttribArray(this.attribs.a_position) if (this.attribs.a_texCoord >= 0) gl.disableVertexAttribArray(this.attribs.a_texCoord) } // ============ 循环控制 ============ start() { if (this._rafId != null || this._destroyed) return if (!this._initialized && !this.init()) return const loop = (t) => { if (this._destroyed) return this.render(t) this._rafId = requestAnimationFrame(loop) } this._rafId = requestAnimationFrame(loop) } stop() { if (this._rafId != null) { cancelAnimationFrame(this._rafId); this._rafId = null } } destroy() { this.stop() this._destroyed = true const gl = this.gl if (!gl) return if (this.program) gl.deleteProgram(this.program) if (this._buffers.quad) gl.deleteBuffer(this._buffers.quad) if (this._fbo) gl.deleteFramebuffer(this._fbo) const texes = [this._textures.baseImage, this._textures.spectrumRamp, this._fboTex] for (const t of texes) { if (t) gl.deleteTexture(t) } const loseExt = gl.getExtension('WEBGL_lose_context') if (loseExt) { try { loseExt.loseContext() } catch (_) {} } this.gl = null } _cleanup() { this.gl = null; this._initialized = false } getFPS() { if (!this._lastFrameTime) return 0 const dt = performance.now() - this._lastFrameTime return dt > 0 ? Math.round(1000 / dt) : 0 } static isSupported() { try { const c = document.createElement('canvas') return !!(c.getContext('webgl') || c.getContext('experimental-webgl')) } catch (_) { return false } } }