jassub
Version:
The Fastest JavaScript SSA/ASS Subtitle Renderer For Browsers
348 lines (335 loc) • 15.8 kB
JavaScript
import { WebGLRendererBase } from "./webgl-base.js";
// GLSL ES 1.0 Vertex Shader with Instancing (using extension)
const VERTEX_SHADER = /* glsl */ `
precision highp float;
// Quad position attribute (0,0), (1,0), (0,1), (1,0), (1,1), (0,1)
attribute vec2 a_quadPos;
uniform vec2 u_resolution;
uniform vec2 u_atlasSize;
// Instance attributes
attribute vec4 a_destRect; // x, y, w, h
attribute vec2 a_srcXY; // atlas x, y
attribute vec4 a_color; // r, g, b, a
varying highp vec2 v_quadPos;
varying highp vec2 v_srcUV;
varying highp vec2 v_sizeUV;
varying vec4 v_color;
void main() {
vec2 pixelPos = a_destRect.xy + a_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_quadPos = a_quadPos;
v_srcUV = a_srcXY / u_atlasSize;
v_sizeUV = a_destRect.zw / u_atlasSize;
v_color = a_color;
}
`;
// GLSL ES 1.0 Fragment Shader
// WebGL1 has no texture arrays or texelFetch, so images are packed into 2D atlas pages.
const FRAGMENT_SHADER = /* glsl */ `
precision mediump float;
uniform sampler2D u_tex;
uniform mat3 u_colorMatrix;
varying highp vec2 v_quadPos;
varying highp vec2 v_srcUV;
varying highp vec2 v_sizeUV;
varying vec4 v_color;
void main() {
// The alpha channel contains the mask.
float mask = texture2D(u_tex, v_srcUV + v_quadPos * v_sizeUV).a;
// 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
gl_FragColor = vec4(correctedColor * a, a);
}
`;
const INITIAL_INSTANCES = 256;
const ATLAS_ROUND = 64;
const MAX_ATLAS_PAGES = 64;
const MAX_ATLAS_BYTES = 256 * 1024 * 1024;
export class WebGL1Renderer extends WebGLRendererBase {
// Extensions
instancedArraysExt;
// Uniform locations
u_tex = null;
u_atlasSize = null;
// Attribute locations
a_quadPos = -1;
a_destRect = -1;
a_srcXY = -1;
a_color = -1;
// Quad vertex buffer (shared for all instances)
quadPosBuffer;
// Instance attribute buffers
instanceDestRectBuffer;
instanceSrcBuffer;
instanceColorBuffer;
// Instance data arrays
instanceDestRectData = new Float32Array(INITIAL_INSTANCES * 4);
instanceSrcData = new Float32Array(INITIAL_INSTANCES * 2);
instanceColorData = new Float32Array(INITIAL_INSTANCES * 4);
instanceCapacity = 0;
atlasPages = [];
atlasSize = 0;
maxTextureSize = 0;
uploadCache = new Set();
// A reusable buffer for strided bitmap data
tightData = null;
constructor(canvas) {
super(canvas);
this._initGL();
}
_initGL() {
const gl = this.canvas.getContext('webgl', {
alpha: true,
premultipliedAlpha: true,
antialias: false,
depth: false,
preserveDrawingBuffer: false,
stencil: false,
desynchronized: true,
powerPreference: 'default'
});
if (!gl) {
throw new Error('Could not get WebGL context');
}
this.gl = gl;
if (!gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT)?.precision) {
throw new Error('WebGL1 does not support highp texture coordinates');
}
this.atlasPages = [];
this.atlasSize = 0;
this.uploadCache.clear();
this.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
// Get instanced arrays extension (required for instancing in WebGL1)
const instancedArraysExt = this.gl.getExtension('ANGLE_instanced_arrays');
if (!instancedArraysExt) {
throw new Error('ANGLE_instanced_arrays extension not supported');
}
this.instancedArraysExt = instancedArraysExt;
this.createProgram(VERTEX_SHADER, FRAGMENT_SHADER);
this.u_resolution = this.gl.getUniformLocation(this.program, 'u_resolution');
this.u_tex = this.gl.getUniformLocation(this.program, 'u_tex');
this.u_colorMatrix = this.gl.getUniformLocation(this.program, 'u_colorMatrix');
this.u_atlasSize = this.gl.getUniformLocation(this.program, 'u_atlasSize');
this.a_quadPos = this.gl.getAttribLocation(this.program, 'a_quadPos');
this.a_destRect = this.gl.getAttribLocation(this.program, 'a_destRect');
this.a_srcXY = this.gl.getAttribLocation(this.program, 'a_srcXY');
this.a_color = this.gl.getAttribLocation(this.program, 'a_color');
// Create quad position buffer (6 vertices for 2 triangles)
this.quadPosBuffer = this.gl.createBuffer();
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.quadPosBuffer);
const quadPositions = new Float32Array([
0.0, 0.0,
1.0, 0.0,
0.0, 1.0,
1.0, 0.0,
1.0, 1.0,
0.0, 1.0
]);
this.gl.bufferData(this.gl.ARRAY_BUFFER, quadPositions, this.gl.STATIC_DRAW);
// Create instance attribute buffers
this.instanceDestRectBuffer = this.gl.createBuffer();
this.instanceSrcBuffer = this.gl.createBuffer();
this.instanceColorBuffer = this.gl.createBuffer();
this.instanceCapacity = 0;
this.ensureInstanceCapacity(INITIAL_INSTANCES);
// Set up vertex attributes
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.quadPosBuffer);
this.gl.enableVertexAttribArray(this.a_quadPos);
this.gl.vertexAttribPointer(this.a_quadPos, 2, this.gl.FLOAT, false, 0, 0);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceDestRectBuffer);
this.gl.enableVertexAttribArray(this.a_destRect);
this.gl.vertexAttribPointer(this.a_destRect, 4, this.gl.FLOAT, false, 0, 0);
this.instancedArraysExt.vertexAttribDivisorANGLE(this.a_destRect, 1);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceSrcBuffer);
this.gl.enableVertexAttribArray(this.a_srcXY);
this.gl.vertexAttribPointer(this.a_srcXY, 2, this.gl.FLOAT, false, 0, 0);
this.instancedArraysExt.vertexAttribDivisorANGLE(this.a_srcXY, 1);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceColorBuffer);
this.gl.enableVertexAttribArray(this.a_color);
this.gl.vertexAttribPointer(this.a_color, 4, this.gl.FLOAT, false, 0, 0);
this.instancedArraysExt.vertexAttribDivisorANGLE(this.a_color, 1);
this.applyCommonState(this.u_tex);
this.gl.uniform2f(this.u_atlasSize, 1, 1);
}
ensureInstanceCapacity(count) {
if (count <= this.instanceCapacity)
return;
this.instanceCapacity = Math.max(count, this.instanceCapacity * 2, INITIAL_INSTANCES);
this.instanceDestRectData = new Float32Array(this.instanceCapacity * 4);
this.instanceSrcData = new Float32Array(this.instanceCapacity * 2);
this.instanceColorData = new Float32Array(this.instanceCapacity * 4);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceDestRectBuffer);
this.gl.bufferData(this.gl.ARRAY_BUFFER, this.instanceCapacity * 16, this.gl.DYNAMIC_DRAW);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceSrcBuffer);
this.gl.bufferData(this.gl.ARRAY_BUFFER, this.instanceCapacity * 8, this.gl.DYNAMIC_DRAW);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceColorBuffer);
this.gl.bufferData(this.gl.ARRAY_BUFFER, this.instanceCapacity * 16, this.gl.DYNAMIC_DRAW);
}
createAtlasPage(size) {
const texture = this.gl.createTexture();
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.ALPHA, size, size, 0, this.gl.ALPHA, this.gl.UNSIGNED_BYTE, null);
// Set texture parameters
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.NEAREST);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.NEAREST);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
return texture;
}
ensureAtlas(size, pages) {
if (this.atlasSize !== size) {
for (const page of this.atlasPages)
this.gl.deleteTexture(page);
this.atlasPages = [];
this.atlasSize = size;
this.uploadCache.clear();
}
while (this.atlasPages.length < pages) {
this.atlasPages.push(this.createAtlasPage(size));
}
}
render(images, heap) {
if (!this.gl || !this.beginFrame(images))
return;
const items = [];
let maxDimension = 0;
let totalArea = 0;
for (const img of images) {
if (img.w <= 0 || img.h <= 0 || img.w > this.maxTextureSize || img.h > this.maxTextureSize)
continue;
// WebGL1 has no UNPACK_ROW_LENGTH. Upload the stride as the texture width instead of copying rows.
let uploadStrided = img.stride >= img.w && img.stride <= this.maxTextureSize &&
img.bitmap + img.stride * img.h <= heap.length;
let packW = uploadStrided ? img.stride : img.w;
if (packW > this.maxTextureSize) {
uploadStrided = false;
packW = img.w;
}
items.push({
img,
packW,
uploadStrided,
x: 0,
y: 0,
layer: 0,
sourceKey: img.cache_id && `${img.cache_id}:${img.bitmap}:${img.w}:${img.h}:${img.stride}`
});
maxDimension = Math.max(maxDimension, packW, img.h);
totalArea += packW * img.h;
}
if (items.length === 0) {
this.gl.flush();
return;
}
const requestedSize = Math.max(ATLAS_ROUND, Math.ceil(Math.max(maxDimension, Math.ceil(Math.sqrt(totalArea))) / ATLAS_ROUND) * ATLAS_ROUND);
let size = Math.min(this.maxTextureSize, Math.max(this.atlasSize, requestedSize));
// Pack images into atlas pages.
let maxPages = Math.max(1, Math.min(MAX_ATLAS_PAGES, Math.floor(MAX_ATLAS_BYTES / (size * size))));
let pageCount = this.packAtlas(items, size);
if (pageCount > maxPages && size < this.maxTextureSize) {
size = this.maxTextureSize;
maxPages = Math.max(1, Math.min(MAX_ATLAS_PAGES, Math.floor(MAX_ATLAS_BYTES / (size * size))));
pageCount = this.packAtlas(items, size);
}
if (pageCount > maxPages) {
console.warn('JASSUB: frame exceeds WebGL1 atlas capacity');
this.gl.flush();
return;
}
this.ensureAtlas(size, pageCount);
this.ensureInstanceCapacity(items.length);
const oldCache = this.uploadCache;
this.uploadCache = new Set();
const pageItems = Array.from({ length: pageCount }, () => []);
for (const item of items)
pageItems[item.layer].push(item);
// Upload only changed images.
for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {
const page = pageItems[pageIndex];
if (!page.length)
continue;
this.gl.bindTexture(this.gl.TEXTURE_2D, this.atlasPages[pageIndex]);
for (const item of page) {
const key = item.sourceKey && `${item.sourceKey}:${item.layer}:${item.x}:${item.y}`;
if (key) {
if (oldCache.has(key) || this.uploadCache.has(key)) {
this.uploadCache.add(key);
continue;
}
this.uploadCache.add(key);
}
const { img } = item;
if (item.uploadStrided) {
this.gl.texSubImage2D(this.gl.TEXTURE_2D, 0, item.x, item.y, item.packW, img.h, this.gl.ALPHA, this.gl.UNSIGNED_BYTE, heap.subarray(img.bitmap, img.bitmap + img.stride * img.h));
}
else {
const length = img.w * img.h;
let tightData = this.tightData;
if (!tightData || tightData.length < length)
tightData = this.tightData = new Uint8Array(length);
const sourceView = heap.subarray(img.bitmap, img.bitmap + img.stride * img.h);
for (let y = 0; y < img.h; y++) {
const srcOffset = y * img.stride;
tightData.set(sourceView.subarray(srcOffset, srcOffset + img.w), y * img.w);
}
this.gl.texSubImage2D(this.gl.TEXTURE_2D, 0, item.x, item.y, img.w, img.h, this.gl.ALPHA, this.gl.UNSIGNED_BYTE, tightData.subarray(0, length));
}
}
}
// Draw one instanced batch per page.
for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {
const page = pageItems[pageIndex];
if (!page.length)
continue;
for (let i = 0; i < page.length; i++) {
const { img, x, y } = page[i];
const d = i * 4;
const s = i * 2;
const c = i * 4;
this.instanceDestRectData[d] = img.dst_x;
this.instanceDestRectData[d + 1] = img.dst_y;
this.instanceDestRectData[d + 2] = img.w;
this.instanceDestRectData[d + 3] = img.h;
this.instanceSrcData[s] = x;
this.instanceSrcData[s + 1] = y;
this.instanceColorData[c] = ((img.color >>> 24) & 0xFF) / 255;
this.instanceColorData[c + 1] = ((img.color >>> 16) & 0xFF) / 255;
this.instanceColorData[c + 2] = ((img.color >>> 8) & 0xFF) / 255;
this.instanceColorData[c + 3] = (img.color & 0xFF) / 255;
}
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceDestRectBuffer);
this.gl.bufferSubData(this.gl.ARRAY_BUFFER, 0, this.instanceDestRectData.subarray(0, page.length * 4));
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceSrcBuffer);
this.gl.bufferSubData(this.gl.ARRAY_BUFFER, 0, this.instanceSrcData.subarray(0, page.length * 2));
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.instanceColorBuffer);
this.gl.bufferSubData(this.gl.ARRAY_BUFFER, 0, this.instanceColorData.subarray(0, page.length * 4));
this.gl.bindTexture(this.gl.TEXTURE_2D, this.atlasPages[pageIndex]);
this.gl.uniform2f(this.u_atlasSize, this.atlasSize, this.atlasSize);
this.instancedArraysExt.drawArraysInstancedANGLE(this.gl.TRIANGLES, 0, 6, page.length);
}
this.gl.flush();
}
destroy() {
if (!this.gl)
return;
// Delete atlas pages.
for (const page of this.atlasPages)
this.gl.deleteTexture(page);
this.atlasPages = [];
this.uploadCache.clear();
this.gl.deleteBuffer(this.quadPosBuffer);
this.gl.deleteBuffer(this.instanceDestRectBuffer);
this.gl.deleteBuffer(this.instanceSrcBuffer);
this.gl.deleteBuffer(this.instanceColorBuffer);
this.gl.deleteProgram(this.program);
this.quadPosBuffer = this.instanceDestRectBuffer = this.instanceSrcBuffer = this.instanceColorBuffer = this.program = this.gl = this.tightData = null;
}
}
//# sourceMappingURL=webgl1-renderer.js.map