422 lines
14 KiB
JavaScript
422 lines
14 KiB
JavaScript
/**
|
||
* 全息镭射卡 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 }
|
||
}
|
||
}
|