UNPKG

jassub

Version:

The Fastest JavaScript SSA/ASS Subtitle Renderer For Browsers

361 lines (343 loc) 15.6 kB
// fallback for browsers that don't support WebGL2 import { colorMatrixConversionMap, IDENTITY_MATRIX, IS_FIREFOX, SHOULD_REFERENCE_MEMORY } from "../util.js"; // GLSL ES 3.0 Vertex Shader with Instancing const VERTEX_SHADER = /* glsl */ `#version 300 es precision mediump float; const vec2 QUAD_POSITIONS[6] = vec2[6]( vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0) ); uniform vec2 u_resolution; // Instance attributes in vec4 a_destRect; // x, y, w, h in vec4 a_color; // r, g, b, a in float a_texLayer; flat out vec2 v_destXY; flat out vec4 v_color; flat out vec2 v_texSize; flat out float v_texLayer; void main() { vec2 quadPos = QUAD_POSITIONS[gl_VertexID]; vec2 pixelPos = a_destRect.xy + quadPos * a_destRect.zw; vec2 clipPos = (pixelPos / u_resolution) * 2.0 - 1.0; clipPos.y = -clipPos.y; gl_Position = vec4(clipPos, 0.0, 1.0); v_destXY = a_destRect.xy; v_color = a_color; v_texSize = a_destRect.zw; v_texLayer = a_texLayer; } `; // GLSL ES 3.0 Fragment Shader - use texelFetch for pixel-perfect sampling const FRAGMENT_SHADER = /* glsl */ `#version 300 es precision mediump float; precision mediump sampler2DArray; uniform sampler2DArray u_texArray; uniform mat3 u_colorMatrix; uniform vec2 u_resolution; flat in vec2 v_destXY; flat in vec4 v_color; flat in vec2 v_texSize; flat in float v_texLayer; out vec4 fragColor; void main() { // Flip Y: WebGL's gl_FragCoord.y is 0 at bottom, but destXY.y is from top vec2 fragPos = vec2(gl_FragCoord.x, u_resolution.y - gl_FragCoord.y); // Calculate local position within the quad (screen coords) vec2 localPos = fragPos - v_destXY; // Convert to integer texel coordinates for texelFetch ivec2 texCoord = ivec2(floor(localPos)); // Bounds check (prevents out-of-bounds access) ivec2 texSizeI = ivec2(v_texSize); if (texCoord.x < 0 || texCoord.y < 0 || texCoord.x >= texSizeI.x || texCoord.y >= texSizeI.y) { discard; } // texelFetch: integer coords, no interpolation, no precision issues float mask = texelFetch(u_texArray, ivec3(texCoord, int(v_texLayer)), 0).r; // Apply color matrix conversion (identity if no conversion needed) vec3 correctedColor = u_colorMatrix * v_color.rgb; // libass color alpha: 0 = opaque, 255 = transparent (inverted) float colorAlpha = 1.0 - v_color.a; // Final alpha = colorAlpha * mask float a = colorAlpha * mask; // Premultiplied alpha output fragColor = vec4(correctedColor * a, a); } `; // Texture array configuration const TEX_ARRAY_SIZE = 64; // Fixed layer count const TEX_INITIAL_SIZE = 256; // Initial width/height const MAX_INSTANCES = 256; // Maximum instances per draw call export class WebGL2Renderer { canvas = null; gl = null; program = null; vao = null; // Uniform locations u_resolution = null; u_texArray = null; u_colorMatrix = null; // Instance attribute buffers instanceDestRectBuffer = null; instanceColorBuffer = null; instanceTexLayerBuffer = null; // Instance data arrays instanceDestRectData; instanceColorData; instanceTexLayerData; texArray = null; texArrayWidth = 0; texArrayHeight = 0; colorMatrix = IDENTITY_MATRIX; constructor() { this.instanceDestRectData = new Float32Array(MAX_INSTANCES * 4); this.instanceColorData = new Float32Array(MAX_INSTANCES * 4); this.instanceTexLayerData = new Float32Array(MAX_INSTANCES); } _scheduledResize; resizeCanvas(width, height) { // WebGL2 doesn't allow 0-sized canvases if (width <= 0 || height <= 0) return; this._scheduledResize = { width, height }; } setCanvas(canvas) { this.canvas = canvas; this.gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, preserveDrawingBuffer: false, stencil: false, desynchronized: true, powerPreference: 'high-performance' }); if (!this.gl) { throw new Error('Could not get WebGL2 context'); } // Create shaders const vertexShader = this.createShader(this.gl.VERTEX_SHADER, VERTEX_SHADER); const fragmentShader = this.createShader(this.gl.FRAGMENT_SHADER, FRAGMENT_SHADER); if (!vertexShader || !fragmentShader) { throw new Error('Failed to create shaders'); } // Create program this.program = this.gl.createProgram(); this.gl.attachShader(this.program, vertexShader); this.gl.attachShader(this.program, fragmentShader); this.gl.linkProgram(this.program); if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) { const info = this.gl.getProgramInfoLog(this.program); throw new Error('Failed to link program: ' + info); } this.gl.deleteShader(vertexShader); this.gl.deleteShader(fragmentShader); // Get uniform locations this.u_resolution = this.gl.getUniformLocation(this.program, 'u_resolution'); this.u_texArray = this.gl.getUniformLocation(this.program, 'u_texArray'); this.u_colorMatrix = this.gl.getUniformLocation(this.program, 'u_colorMatrix'); // Create instance attribute buffers this.instanceDestRectBuffer = this.gl.createBuffer(); this.instanceColorBuffer = this.gl.createBuffer(); this.instanceTexLayerBuffer = this.gl.createBuffer(); // Create a VAO (required for WebGL2) this.vao = this.gl.createVertexArray(); this.gl.bindVertexArray(this.vao); // Setup instance attributes const destRectLoc = this.gl.getAttribLocation(this.program, 'a_destRect'); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceDestRectBuffer); this.gl.enableVertexAttribArray(destRectLoc); this.gl.vertexAttribPointer(destRectLoc, 4, this.gl.FLOAT, false, 0, 0); this.gl.vertexAttribDivisor(destRectLoc, 1); const colorLoc = this.gl.getAttribLocation(this.program, 'a_color'); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceColorBuffer); this.gl.enableVertexAttribArray(colorLoc); this.gl.vertexAttribPointer(colorLoc, 4, this.gl.FLOAT, false, 0, 0); this.gl.vertexAttribDivisor(colorLoc, 1); const texLayerLoc = this.gl.getAttribLocation(this.program, 'a_texLayer'); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceTexLayerBuffer); this.gl.enableVertexAttribArray(texLayerLoc); this.gl.vertexAttribPointer(texLayerLoc, 1, this.gl.FLOAT, false, 0, 0); this.gl.vertexAttribDivisor(texLayerLoc, 1); // Set up blending for premultiplied alpha this.gl.enable(this.gl.BLEND); this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); // Use the program this.gl.useProgram(this.program); // Set texture unit this.gl.uniform1i(this.u_texArray, 0); // Set initial color matrix this.gl.uniformMatrix3fv(this.u_colorMatrix, false, this.colorMatrix); // Set one-time GL state this.gl.pixelStorei(this.gl.UNPACK_ALIGNMENT, 1); this.gl.clearColor(0, 0, 0, 0); this.gl.activeTexture(this.gl.TEXTURE0); // Create initial texture array this.createTexArray(TEX_INITIAL_SIZE, TEX_INITIAL_SIZE); } createShader(type, source) { const shader = this.gl.createShader(type); this.gl.shaderSource(shader, source); this.gl.compileShader(shader); if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { const info = this.gl.getShaderInfoLog(shader); console.log(info); this.gl.deleteShader(shader); return null; } return shader; } // Set the color matrix for color space conversion. // Pass null or undefined to use identity (no conversion). setColorMatrix(subtitleColorSpace, videoColorSpace) { this.colorMatrix = (subtitleColorSpace && videoColorSpace && colorMatrixConversionMap[subtitleColorSpace]?.[videoColorSpace]) ?? IDENTITY_MATRIX; if (this.gl && this.u_colorMatrix && this.program) { this.gl.useProgram(this.program); this.gl.uniformMatrix3fv(this.u_colorMatrix, false, this.colorMatrix); } } createTexArray(width, height) { if (this.texArray) { this.gl.deleteTexture(this.texArray); } this.texArray = this.gl.createTexture(); this.gl.bindTexture(this.gl.TEXTURE_2D_ARRAY, this.texArray); // Allocate storage for texture array this.gl.texImage3D(this.gl.TEXTURE_2D_ARRAY, 0, this.gl.R8, width, height, TEX_ARRAY_SIZE, 0, this.gl.RED, this.gl.UNSIGNED_BYTE, null // Firefox cries about uninitialized data, but is slower with zero initialized data... ); // Set texture parameters (no filtering needed for texelFetch, but set anyway) this.gl.texParameteri(this.gl.TEXTURE_2D_ARRAY, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST); this.gl.texParameteri(this.gl.TEXTURE_2D_ARRAY, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST); this.gl.texParameteri(this.gl.TEXTURE_2D_ARRAY, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE); this.gl.texParameteri(this.gl.TEXTURE_2D_ARRAY, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE); this.texArrayWidth = width; this.texArrayHeight = height; } render(images, heap) { if (!this.gl || !this.program || !this.vao || !this.texArray) return; // HACK 1 and 2 [see above for explanation] if ((self.HEAPU8RAW.buffer !== self.WASMMEMORY.buffer) || SHOULD_REFERENCE_MEMORY) { heap = self.HEAPU8RAW = new Uint8Array(self.WASMMEMORY.buffer); } // we scheduled a resize because changing the canvas size clears it, and we don't want it to flicker // so we do it here, right before rendering if (this._scheduledResize) { const { width, height } = this._scheduledResize; this._scheduledResize = undefined; this.canvas.width = width; this.canvas.height = height; // Update viewport and resolution uniform this.gl.viewport(0, 0, width, height); this.gl.uniform2f(this.u_resolution, width, height); } else { // Clear canvas this.gl.clear(this.gl.COLOR_BUFFER_BIT); } // Find max dimensions needed and filter valid images let maxW = this.texArrayWidth; let maxH = this.texArrayHeight; const validImages = []; for (const img of images) { if (img.w <= 0 || img.h <= 0) continue; validImages.push(img); if (img.w > maxW) maxW = img.w; if (img.h > maxH) maxH = img.h; } if (validImages.length === 0) return; // Resize texture array if needed if (maxW > this.texArrayWidth || maxH > this.texArrayHeight) { this.createTexArray(maxW, maxH); } // Process images in chunks that fit within texture array size const batchSize = Math.min(TEX_ARRAY_SIZE, MAX_INSTANCES); for (let batchStart = 0; batchStart < validImages.length; batchStart += batchSize) { const batchEnd = Math.min(batchStart + batchSize, validImages.length); let instanceCount = 0; // Upload textures for this batch for (let i = batchStart; i < batchEnd; i++) { const img = validImages[i]; const layer = instanceCount; // Upload bitmap data to texture array layer this.gl.pixelStorei(this.gl.UNPACK_ROW_LENGTH, img.stride); if (IS_FIREFOX) { // HACK 3 [see above for explanation] const sourceView = new Uint8Array(heap.buffer, img.bitmap, img.stride * img.h); const bitmapData = new Uint8Array(sourceView); this.gl.texSubImage3D(this.gl.TEXTURE_2D_ARRAY, 0, 0, 0, layer, // x, y, z offset img.w, img.h, 1, // depth (1 layer) this.gl.RED, this.gl.UNSIGNED_BYTE, bitmapData); } else { this.gl.texSubImage3D(this.gl.TEXTURE_2D_ARRAY, 0, 0, 0, layer, // x, y, z offset img.w, img.h, 1, // depth (1 layer) this.gl.RED, this.gl.UNSIGNED_BYTE, heap, img.bitmap); } // Fill instance data const idx = instanceCount * 4; this.instanceDestRectData[idx] = img.dst_x; this.instanceDestRectData[idx + 1] = img.dst_y; this.instanceDestRectData[idx + 2] = img.w; this.instanceDestRectData[idx + 3] = img.h; this.instanceColorData[idx] = ((img.color >>> 24) & 0xFF) / 255; this.instanceColorData[idx + 1] = ((img.color >>> 16) & 0xFF) / 255; this.instanceColorData[idx + 2] = ((img.color >>> 8) & 0xFF) / 255; this.instanceColorData[idx + 3] = (img.color & 0xFF) / 255; this.instanceTexLayerData[instanceCount] = layer; instanceCount++; } this.gl.pixelStorei(this.gl.UNPACK_ROW_LENGTH, 0); if (instanceCount === 0) continue; // Upload instance data to buffers this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceDestRectBuffer); this.gl.bufferData(this.gl.ARRAY_BUFFER, this.instanceDestRectData.subarray(0, instanceCount * 4), this.gl.DYNAMIC_DRAW); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceColorBuffer); this.gl.bufferData(this.gl.ARRAY_BUFFER, this.instanceColorData.subarray(0, instanceCount * 4), this.gl.DYNAMIC_DRAW); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceTexLayerBuffer); this.gl.bufferData(this.gl.ARRAY_BUFFER, this.instanceTexLayerData.subarray(0, instanceCount), this.gl.DYNAMIC_DRAW); // Single instanced draw call this.gl.drawArraysInstanced(this.gl.TRIANGLES, 0, 6, instanceCount); } } destroy() { if (this.gl) { if (this.texArray) { this.gl.deleteTexture(this.texArray); this.texArray = null; } if (this.instanceDestRectBuffer) { this.gl.deleteBuffer(this.instanceDestRectBuffer); this.instanceDestRectBuffer = null; } if (this.instanceColorBuffer) { this.gl.deleteBuffer(this.instanceColorBuffer); this.instanceColorBuffer = null; } if (this.instanceTexLayerBuffer) { this.gl.deleteBuffer(this.instanceTexLayerBuffer); this.instanceTexLayerBuffer = null; } if (this.vao) { this.gl.deleteVertexArray(this.vao); this.vao = null; } if (this.program) { this.gl.deleteProgram(this.program); this.program = null; } this.gl = null; } } } //# sourceMappingURL=webgl2-renderer.js.map