topfans/frontend/utils/webgl/holographic-engine.js
2026-05-16 02:42:32 +08:00

422 lines
14 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.

/**
* 全息镭射卡 WebGL 渲染引擎
*
* 反模糊关键设计:
* 1. Canvas 分辨率 = CSS尺寸 × DPR上限2保证像素密度
* 2. 基础图像纹理启用 MipmapGL_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 }
}
}