From 83c50d93a211b8187184b5415274166d4ed3343f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:41:56 +0200 Subject: [PATCH] fix(subtitles): flatten atlas-overflow ASS frames into an RGBA composite Signs built from hundreds of overlapping paint-stroke drawings (masked smartphone screens and similar typesetting) sum to far more bitmap area than the paged ALPHA_8 atlas can hold: the issue sample needs 5 pages of 16M px at 1080p and 19 at 4K against the 4-page cap, so the packer dropped the painter-order tail - the sign's text and late mask strokes. Move the packer out of the JNI file into AssPack.c (pure C, compilable against a desktop libass for verification) and add a composite fallback: when a frame can never fit MAX_ATLAS_PAGES pages or the vertex budget, blend the image list CPU-side into one premultiplied RGBA rect over the union bounding box - O(frame area) instead of O(sum of image areas) - and draw it as a single quad through a new MODE_COMPOSITE path in the GL renderer. Oversized composites reuse the existing grow-and-re-render contract; the atlas fast path is byte-identical for every frame that fits. Verified with a desktop harness compiling the shipped AssPack.c against fork libass 0.18.3 and the issue sample: all atlas-mode frames byte-match the previous packer, the sign's frames composite with zero truncation and byte-match a reference full-frame blend at 1080p and 4K, and the multi-page composite grow path round-trips. close #1868 --- android/libass/src/main/cpp/AssKt.c | 329 +++--------------- android/libass/src/main/cpp/AssPack.c | 300 ++++++++++++++++ android/libass/src/main/cpp/AssPack.h | 57 +++ android/libass/src/main/cpp/CMakeLists.txt | 2 +- .../com/edde746/plezy/libass/AssAtlasFrame.kt | 48 ++- .../com/edde746/plezy/libass/AssRender.kt | 7 +- .../media/widget/AssSubtitleAtlasPipeline.kt | 96 ++++- 7 files changed, 528 insertions(+), 311 deletions(-) create mode 100644 android/libass/src/main/cpp/AssPack.c create mode 100644 android/libass/src/main/cpp/AssPack.h diff --git a/android/libass/src/main/cpp/AssKt.c b/android/libass/src/main/cpp/AssKt.c index f76992e7..c593b91a 100644 --- a/android/libass/src/main/cpp/AssKt.c +++ b/android/libass/src/main/cpp/AssKt.c @@ -18,6 +18,7 @@ static inline long long nowMs(void) { return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; } +#include "AssPack.h" #include "ass/ass.h" #define LOG_TAG "SubtitleRenderer" @@ -243,35 +244,8 @@ Java_com_edde746_plezy_libass_AssRender_nativeAssRenderDeinit(JNIEnv* env, jclas } } -// Hard cap on atlas pages (see the packing comment below). 4 pages of a GL-max -// texture is far above the worst real frame measured (a 4K-rendered full-screen -// typeset letter needs 3); beyond it tiles are dropped and counted in truncated. -#define MAX_ATLAS_PAGES 4 - -// A tile is a <= atlasMaxW x atlasMaxH sub-rect of an ASS_Image. A single image -// can exceed one atlas page only when the render frame is larger than a page -// (>4K, or a GPU whose max texture is below the frame) — multi-page can't split -// one image across pages (a quad samples one texture), so tiling does, keeping -// the never-drop guarantee. (#1436 itself was atlas-AREA overflow, fixed by the -// multi-page pack below, not oversized single images.) Tiles are built in list -// order (= libass blend/painter order, preserved for emission); the single-page -// pack runs height-sorted via a separate key array so emission order is untouched. -typedef struct { - ASS_Image* img; // source image (for bitmap/stride/color/dst_x/dst_y) - int ox, oy; // tile offset within the source bitmap - int tw, th; // tile size (<= atlasMaxW x atlasMaxH) - int page; // atlas page the tile is packed into; -1 if dropped for capacity - int sx, sy; // packed slot within the page; valid when page >= 0 -} PackTile; - -typedef struct { - int th; // tile height (the sort key) - int idx; // index into the build-order tiles[] array -} TileSortKey; - -static int compareTileKeysByHeightDesc(const void* a, const void* b) { - return ((const TileSortKey*)b)->th - ((const TileSortKey*)a)->th; -} +// Packing/composite policy lives in AssPack.c (pure C, desktop-testable); this +// file owns the JNI boundary, buffer plumbing and logging. static int imageListHasOutput(ASS_Image* image) { for (ASS_Image* img = image; img != NULL; img = img->next) { @@ -293,11 +267,12 @@ static int truncationLogCounter = 0; // [5]=hasOutput [6]=pageCount // [7 .. 7+MAX-1] = pageHeights[pageCount] // [7+MAX .. 7+2*MAX-1] = pageQuadCounts[pageCount] -#define ASS_HEADER_INTS (7 + 2 * MAX_ATLAS_PAGES) +// [7+2*MAX] = mode (ASS_PACK_MODE_ATLAS | ASS_PACK_MODE_COMPOSITE) +#define ASS_HEADER_INTS (7 + 2 * ASS_PACK_MAX_PAGES + 1) static jint writeAtlasHeader( JNIEnv* env, jintArray headerBuf, int atlasWidth, int quadCount, int changed, int truncated, int requiredPages, - int hasOutput, int pageCount, const int* pageHeights, const int* pageQuads) { + int hasOutput, int pageCount, const int* pageHeights, const int* pageQuads, int mode) { int hdr[ASS_HEADER_INTS]; memset(hdr, 0, sizeof(hdr)); hdr[0] = atlasWidth; @@ -307,36 +282,32 @@ static jint writeAtlasHeader( hdr[4] = requiredPages; hdr[5] = hasOutput; hdr[6] = pageCount; - for (int i = 0; i < pageCount && i < MAX_ATLAS_PAGES; i++) { + for (int i = 0; i < pageCount && i < ASS_PACK_MAX_PAGES; i++) { hdr[7 + i] = pageHeights ? pageHeights[i] : 0; - hdr[7 + MAX_ATLAS_PAGES + i] = pageQuads ? pageQuads[i] : 0; + hdr[7 + ASS_PACK_MAX_PAGES + i] = pageQuads ? pageQuads[i] : 0; } + hdr[7 + 2 * ASS_PACK_MAX_PAGES] = mode; (*env)->SetIntArrayRegion(env, headerBuf, 0, ASS_HEADER_INTS, hdr); return 1; } // Renders a frame into the provided atlas + vertex direct ByteBuffers. // -// - atlasBuf holds one or more vertically-stacked ALPHA_8 *pages*, each atlasMaxW × -// atlasMaxH (row stride atlasMaxW); page p starts at byte offset p*atlasMaxW*atlasMaxH. -// The buffer's capacity bounds how many pages this render may fill; AssAtlasFrame -// reports pageHeights (rows worth uploading per page) and requiredPages. +// - In the common ATLAS mode, atlasBuf holds one or more vertically-stacked ALPHA_8 +// *pages*, each atlasMaxW × atlasMaxH (row stride atlasMaxW); page p starts at byte +// offset p*atlasMaxW*atlasMaxH. The buffer's capacity bounds how many pages this +// render may fill; AssAtlasFrame reports pageHeights (rows worth uploading per page) +// and requiredPages. UVs are page-local, normalized against atlasMaxW × atlasMaxH. +// - In COMPOSITE mode (frames whose tiles can never fit ASS_PACK_MAX_PAGES pages or +// the vertex budget) atlasBuf instead starts with one premultiplied RGBA rect of +// atlasWidth × pageHeights[0] pixels, drawn as the single emitted quad (UVs 0..1). // - vertexBuf holds a per-quad vertex stream (6 vertices × (2 pos + 2 uv + 4 color) // floats = 48 floats = 192 bytes per quad). Must match BYTES_PER_QUAD/VERTEX in -// AssSubtitleAtlasPipeline.kt. UVs are page-local, normalized against atlasMaxW × -// atlasMaxH (the per-page texture dims). -// - The common case packs everything into a single height-sorted page (minimizes -// packed height, byte-identical to the prior single-page packer). When that -// overflows (a 4K full-screen sign can exceed one GL-max texture), the packer -// spills into additional pages in list order: page assignment is monotonic in -// libass's painter order, so each page's quads are one contiguous run in the -// vertex stream and drawing the pages in turn reproduces the blend order. -// - Vertices are always emitted in original list order (= libass's painter order). +// AssSubtitleAtlasPipeline.kt. Vertices are emitted in libass's painter order. // -// Never fails on content size: when the frame needs more pages than the buffer holds -// (requiredPages > pageHeights.size) the caller grows the buffer and re-renders; any -// genuinely undrawable tiles (past MAX_ATLAS_PAGES / the vertex budget) are dropped -// and counted in truncated. +// Never drops content for size: when the frame needs more capacity than the buffer +// holds (requiredPages > pageHeights.size) the caller grows the buffer and re-renders; +// frames too dense for the paged atlas flatten into the RGBA composite (see AssPack.c). // // Returns 0 for missing buffers/handles (the caller maps that to a null frame); 1 when // the header was written. On changed == 0 the header carries (atlasWidth=0, quadCount=0, @@ -359,7 +330,7 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, hasOutput=%d)", (long long)time, tAss - t0, changed, hasOutput); } - return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, hasOutput, 1, NULL, NULL); + return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, hasOutput, 1, NULL, NULL, ASS_PACK_MODE_ATLAS); } if (image == NULL) { @@ -368,7 +339,7 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, no output)", (long long)time, tAss - t0, changed); } - return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL); + return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL, ASS_PACK_MODE_ATLAS); } uint8_t* atlasPixels = (uint8_t*)(*env)->GetDirectBufferAddress(env, atlasBuf); @@ -382,253 +353,45 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr (long long)atlasCap); return 0; } - // 48 floats per quad × 4 bytes = 192 bytes/quad - const int maxQuads = (int)(vertexCap / 192); - const size_t pageBytes = (size_t)atlasMaxW * atlasMaxH; - int providedPages = (int)(atlasCap / (jlong)pageBytes); - if (providedPages < 1) return 0; // one page is guaranteed above; keep the page math safe - if (providedPages > MAX_ATLAS_PAGES) providedPages = MAX_ATLAS_PAGES; - // Split every image into <= atlasMaxW x atlasMaxH tiles, then pack the tiles. - // tiles[] stays in list order (= blend/painter order for emission); keys[] is - // sorted by height for the single-page pack so it produces tight rows. - int total = 0; - for (ASS_Image* img = image; img != NULL; img = img->next) { - if (img->w > 0 && img->h > 0) { - int cols = (img->w + atlasMaxW - 1) / atlasMaxW; - int rows = (img->h + atlasMaxH - 1) / atlasMaxH; - total += cols * rows; - } - } - if (total == 0) { - return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL); - } - - PackTile* tiles = (PackTile*)malloc(sizeof(PackTile) * (size_t)total); - TileSortKey* keys = (TileSortKey*)malloc(sizeof(TileSortKey) * (size_t)total); - if (!tiles || !keys) { - free(tiles); - free(keys); + AssPackResult pack; + if (!ass_pack_frame(image, atlasPixels, (size_t)atlasCap, atlasMaxW, atlasMaxH, vertices, (size_t)vertexCap, &pack)) { return 0; } - int n = 0; - long long srcPixels = 0; - for (ASS_Image* img = image; img != NULL; img = img->next) { - if (img->w <= 0 || img->h <= 0) continue; - srcPixels += (long long)img->w * img->h; - for (int oy = 0; oy < img->h; oy += atlasMaxH) { - int th = img->h - oy; - if (th > atlasMaxH) th = atlasMaxH; - for (int ox = 0; ox < img->w; ox += atlasMaxW) { - int tw = img->w - ox; - if (tw > atlasMaxW) tw = atlasMaxW; - tiles[n] = (PackTile){.img = img, .ox = ox, .oy = oy, .tw = tw, .th = th, .page = -1, .sx = -1, .sy = -1}; - keys[n] = (TileSortKey){.th = th, .idx = n}; - n++; - } - } - } - int pageHeights[MAX_ATLAS_PAGES] = {0}; - int pageQuads[MAX_ATLAS_PAGES] = {0}; - int pageCount = 1; - int requiredPages = 1; - int truncated = 0; - // Pass 1a: height-sorted single page — the common case, minimal packed height - // (byte-identical to the prior single-page packer when the frame fits one page). - qsort(keys, (size_t)n, sizeof(TileSortKey), compareTileKeysByHeightDesc); - int cursorX = 0, cursorY = 0, rowH = 0, packedH = 0, accepted = 0; - for (int i = 0; i < n; i++) { - PackTile* t = &tiles[keys[i].idx]; - if (accepted >= maxQuads) break; - int cx = cursorX, cy = cursorY, rh = rowH; - if (cx + t->tw > atlasMaxW) { - cy += rh; - cx = 0; - rh = 0; - } - if (cy + t->th > atlasMaxH) continue; // doesn't fit a single page - t->page = 0; - t->sx = cx; - t->sy = cy; - cursorX = cx + t->tw; - cursorY = cy; - rowH = (t->th > rh) ? t->th : rh; - if (cy + t->th > packedH) packedH = cy + t->th; - accepted++; - } - - if (accepted == n) { - pageHeights[0] = packedH; - pageQuads[0] = accepted; - } else { - // Pass 1b: the frame overflows one page. Re-pack in list order, starting a new - // page whenever a tile won't fit the current one. List order keeps the page - // index monotonic in painter order, so each page's quads stay one contiguous run. - for (int i = 0; i < n; i++) { - tiles[i].page = -1; - tiles[i].sx = -1; - tiles[i].sy = -1; - } - int page = 0, cx = 0, cy = 0, rh = 0, placed = 0; - for (int i = 0; i < n; i++) { - PackTile* t = &tiles[i]; - if (cx + t->tw > atlasMaxW) { - cy += rh; - cx = 0; - rh = 0; - } - if (cy + t->th > atlasMaxH) { - page++; - cx = 0; - cy = 0; - rh = 0; - } - if (page + 1 > requiredPages) requiredPages = page + 1; - if (page < providedPages && placed < maxQuads) { - t->page = page; - t->sx = cx; - t->sy = cy; - if (cy + t->th > pageHeights[page]) pageHeights[page] = cy + t->th; - pageQuads[page]++; - placed++; - } - cx += t->tw; - rh = (t->th > rh) ? t->th : rh; - } - pageCount = (requiredPages < providedPages) ? requiredPages : providedPages; - accepted = placed; - truncated = n - placed; - } - - // Warn only for genuinely-unrecoverable loss. A frame that needs more pages than - // the buffer currently holds, yet fits within MAX_ATLAS_PAGES and the vertex - // budget, is recoverable: the caller grows the buffer and re-renders, so the - // first (discarded) render's truncated > 0 is a false alarm, not data loss. Tiles - // are only truly lost past the page cap or the vertex budget. - const int recoverableGrow = requiredPages <= MAX_ATLAS_PAGES && n <= maxQuads; - if (truncated > 0 && !recoverableGrow && (truncationLogCounter++ & 63) == 0) { + // Warn only for genuinely-unrecoverable loss. A frame that needs more capacity than + // the buffer currently holds is recoverable: the caller grows the buffer and + // re-renders, so the first (discarded) render's truncated > 0 is a false alarm, not + // data loss. With the composite fallback, unrecoverable truncation should be + // unreachable for real content. + const int maxQuads = (int)(vertexCap / 192); + const int recoverableGrow = + pack.requiredPages <= ASS_PACK_MAX_PAGES && (pack.mode == ASS_PACK_MODE_COMPOSITE || pack.totalTiles <= maxQuads); + if (pack.truncated > 0 && !recoverableGrow && (truncationLogCounter++ & 63) == 0) { __android_log_print( - ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, need %d pages have %d)", - truncated, n, atlasMaxW, atlasMaxH, requiredPages, providedPages); + ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, need %d pages have %lld)", + pack.truncated, pack.totalTiles, atlasMaxW, atlasMaxH, pack.requiredPages, + (long long)(atlasCap / ((jlong)atlasMaxW * atlasMaxH))); } - if (accepted == 0) { - free(tiles); - free(keys); - return writeAtlasHeader(env, headerBuf, 0, 0, changed, truncated, requiredPages, 1, 1, NULL, NULL); - } - - // Clear only the packed rows of each written page. - for (int p = 0; p < pageCount; p++) { - memset(atlasPixels + (size_t)p * pageBytes, 0, (size_t)atlasMaxW * pageHeights[p]); - } - - // Emit tiles in list order (= libass's painter/blend order), copying each placed - // tile into its page slot and emitting its quad. Monotonic page assignment makes - // each page's quads a contiguous run, matching pageQuads[] for the per-page draw. - int qi = 0; - for (int i = 0; i < n; i++) { - PackTile* t = &tiles[i]; - if (t->page < 0) continue; - ASS_Image* img = t->img; - const int px = t->sx; - const int py = t->sy; - uint8_t* pageBase = atlasPixels + (size_t)t->page * pageBytes; - - for (int y = 0; y < t->th; y++) { - uint8_t* dst = pageBase + (size_t)(py + y) * atlasMaxW + px; - const uint8_t* src = img->bitmap + (size_t)(t->oy + y) * img->stride + t->ox; - memcpy(dst, src, (size_t)t->tw); - } - - const float x0 = (float)(img->dst_x + t->ox); - const float y0 = (float)(img->dst_y + t->oy); - const float x1 = x0 + (float)t->tw; - const float y1 = y0 + (float)t->th; - const float u0 = (float)px / (float)atlasMaxW; - const float v0 = (float)py / (float)atlasMaxH; - const float u1 = (float)(px + t->tw) / (float)atlasMaxW; - const float v1 = (float)(py + t->th) / (float)atlasMaxH; - const unsigned int c = img->color; - const float r = (float)((c >> 24) & 0xFFu) / 255.0f; - const float g = (float)((c >> 16) & 0xFFu) / 255.0f; - const float b = (float)((c >> 8) & 0xFFu) / 255.0f; - const float a = (float)(0xFFu - (c & 0xFFu)) / 255.0f; - - float* vx = vertices + (size_t)qi * 48; - // 8 floats per vertex: x, y, u, v, r, g, b, a. - // Triangle 1: (x0,y0) (x1,y0) (x0,y1) - vx[0] = x0; - vx[1] = y0; - vx[2] = u0; - vx[3] = v0; - vx[4] = r; - vx[5] = g; - vx[6] = b; - vx[7] = a; - vx[8] = x1; - vx[9] = y0; - vx[10] = u1; - vx[11] = v0; - vx[12] = r; - vx[13] = g; - vx[14] = b; - vx[15] = a; - vx[16] = x0; - vx[17] = y1; - vx[18] = u0; - vx[19] = v1; - vx[20] = r; - vx[21] = g; - vx[22] = b; - vx[23] = a; - // Triangle 2: (x1,y0) (x1,y1) (x0,y1) - vx[24] = x1; - vx[25] = y0; - vx[26] = u1; - vx[27] = v0; - vx[28] = r; - vx[29] = g; - vx[30] = b; - vx[31] = a; - vx[32] = x1; - vx[33] = y1; - vx[34] = u1; - vx[35] = v1; - vx[36] = r; - vx[37] = g; - vx[38] = b; - vx[39] = a; - vx[40] = x0; - vx[41] = y1; - vx[42] = u0; - vx[43] = v1; - vx[44] = r; - vx[45] = g; - vx[46] = b; - vx[47] = a; - - qi++; - } - - free(tiles); - free(keys); // Slow-render breakdown: separates libass's own cost (rasterize/blur/shape) - // from this function's packing + memcpy, so device logs attribute the time. + // from this function's packing/compositing, so device logs attribute the time. const long long tEnd = nowMs(); if (tEnd - t0 > 40) { __android_log_print( ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: total=%lldms ass=%lldms pack+copy=%lldms images=%d srcPx=%lldk " - "atlas=%dx%d pages=%d quads=%d", - (long long)time, tEnd - t0, tAss - t0, tEnd - tAss, n, srcPixels / 1000, atlasMaxW, atlasMaxH, pageCount, qi); + "atlas=%dx%d pages=%d quads=%d mode=%d", + (long long)time, tEnd - t0, tAss - t0, tEnd - tAss, pack.totalTiles, pack.srcPixels / 1000, atlasMaxW, + atlasMaxH, pack.pageCount, pack.quadCount, pack.mode); } - // atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width); - // pageHeights/pageQuadCounts describe the per-page upload + draw ranges. + // ATLAS: atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width) + // and pageHeights/pageQuadCounts describe the per-page upload + draw ranges. + // COMPOSITE: atlasWidth × pageHeights[0] are the RGBA rect dims for the one quad. return writeAtlasHeader( - env, headerBuf, atlasMaxW, qi, changed, truncated, requiredPages, 1, pageCount, pageHeights, pageQuads); + env, headerBuf, pack.atlasWidth, pack.quadCount, changed, pack.truncated, pack.requiredPages, + pack.totalTiles > 0 ? 1 : 0, pack.pageCount, pack.pageHeights, pack.pageQuads, pack.mode); } // --- AssFrameTimestamps (EGL_ANDROID_get_frame_timestamps) --- diff --git a/android/libass/src/main/cpp/AssPack.c b/android/libass/src/main/cpp/AssPack.c new file mode 100644 index 00000000..2b311907 --- /dev/null +++ b/android/libass/src/main/cpp/AssPack.c @@ -0,0 +1,300 @@ +#include "AssPack.h" + +#include +#include +#include + +// A tile is a <= atlasMaxW x atlasMaxH sub-rect of an ASS_Image. A single image +// can exceed one atlas page only when the render frame is larger than a page +// (>4K, or a GPU whose max texture is below the frame) — multi-page can't split +// one image across pages (a quad samples one texture), so tiling does, keeping +// the never-drop guarantee. (#1436 itself was atlas-AREA overflow, fixed by the +// multi-page pack below, not oversized single images.) Tiles are built in list +// order (= libass blend/painter order, preserved for emission); the single-page +// pack runs height-sorted via a separate key array so emission order is untouched. +typedef struct { + ASS_Image* img; // source image (for bitmap/stride/color/dst_x/dst_y) + int ox, oy; // tile offset within the source bitmap + int tw, th; // tile size (<= atlasMaxW x atlasMaxH) + int page; // atlas page the tile is packed into; -1 if dropped for capacity + int sx, sy; // packed slot within the page; valid when page >= 0 +} PackTile; + +typedef struct { + int th; // tile height (the sort key) + int idx; // index into the build-order tiles[] array +} TileSortKey; + +static int compareTileKeysByHeightDesc(const void* a, const void* b) { + return ((const TileSortKey*)b)->th - ((const TileSortKey*)a)->th; +} + +// 8 floats per vertex (x, y, u, v, r, g, b, a) x 6 vertices; layout must match +// BYTES_PER_QUAD/VERTEX in AssSubtitleAtlasPipeline.kt. +static void emitQuad( + float* vx, float x0, float y0, float x1, float y1, float u0, float v0, float u1, float v1, float r, float g, + float b, float a) { + const float pos[6][2] = {{x0, y0}, {x1, y0}, {x0, y1}, {x1, y0}, {x1, y1}, {x0, y1}}; + const float uv[6][2] = {{u0, v0}, {u1, v0}, {u0, v1}, {u1, v0}, {u1, v1}, {u0, v1}}; + for (int i = 0; i < 6; i++) { + *vx++ = pos[i][0]; + *vx++ = pos[i][1]; + *vx++ = uv[i][0]; + *vx++ = uv[i][1]; + *vx++ = r; + *vx++ = g; + *vx++ = b; + *vx++ = a; + } +} + +// Flattens the whole image list into one premultiplied RGBA rect (the union +// bounding box) at the start of `atlasPixels`, emitted as a single quad. The +// blend is libass painter-order src-over, the same math the GL path applies to +// alpha-atlas quads, so output is visually identical — memory just becomes +// O(union area <= frame area) instead of O(sum of image areas). Used for +// frames whose summed image area cannot fit ASS_PACK_MAX_PAGES alpha pages +// (#1868: ~150 overlapping paint-strokes put ~5 pages of tiles at 1080p and +// ~19 at 4K behind a 4-page cap, silently dropping the painter-order tail — +// the sign's text). +static void compositeFrame( + ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, size_t pageBytes, float* vertices, size_t vertexCap, + AssPackResult* out) { + int ux0 = INT_MAX, uy0 = INT_MAX, ux1 = INT_MIN, uy1 = INT_MIN; + for (ASS_Image* img = image; img != NULL; img = img->next) { + if (img->w <= 0 || img->h <= 0) continue; + if (img->dst_x < ux0) ux0 = img->dst_x; + if (img->dst_y < uy0) uy0 = img->dst_y; + if (img->dst_x + img->w > ux1) ux1 = img->dst_x + img->w; + if (img->dst_y + img->h > uy1) uy1 = img->dst_y + img->h; + } + const int uw = ux1 - ux0; + const int uh = uy1 - uy0; + const size_t rgbaBytes = (size_t)uw * uh * 4; + + out->mode = ASS_PACK_MODE_COMPOSITE; + out->requiredPages = (int)((rgbaBytes + pageBytes - 1) / pageBytes); + out->pageCount = 1; + if (rgbaBytes > atlasCap || vertexCap < 192) { + // Buffers too small for the flattened rect: report the needed capacity and + // write nothing — the caller grows and re-renders (same contract as the + // multi-page atlas grow). truncated flags the frame as not presentable. + out->truncated = out->totalTiles; + return; + } + + memset(atlasPixels, 0, rgbaBytes); + for (ASS_Image* img = image; img != NULL; img = img->next) { + if (img->w <= 0 || img->h <= 0) continue; + const unsigned int c = img->color; + const unsigned cr = (c >> 24) & 0xFFu; + const unsigned cg = (c >> 16) & 0xFFu; + const unsigned cb = (c >> 8) & 0xFFu; + const unsigned ca = 0xFFu - (c & 0xFFu); + if (ca == 0) continue; + for (int y = 0; y < img->h; y++) { + const uint8_t* src = img->bitmap + (size_t)y * img->stride; + uint8_t* dst = atlasPixels + (((size_t)(img->dst_y - uy0 + y) * uw) + (size_t)(img->dst_x - ux0)) * 4; + for (int x = 0; x < img->w; x++, dst += 4) { + const unsigned a = (src[x] * ca + 127u) / 255u; + if (a == 0) continue; + const unsigned inv = 255u - a; + dst[0] = (uint8_t)((cr * a + dst[0] * inv + 127u) / 255u); + dst[1] = (uint8_t)((cg * a + dst[1] * inv + 127u) / 255u); + dst[2] = (uint8_t)((cb * a + dst[2] * inv + 127u) / 255u); + dst[3] = (uint8_t)((255u * a + dst[3] * inv + 127u) / 255u); + } + } + } + + out->atlasWidth = uw; + out->pageHeights[0] = uh; + out->pageQuads[0] = 1; + out->quadCount = 1; + out->truncated = 0; + emitQuad( + vertices, (float)ux0, (float)uy0, (float)(ux0 + uw), (float)(uy0 + uh), 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, + 1.0f); +} + +int ass_pack_frame( + ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, int atlasMaxW, int atlasMaxH, float* vertices, + size_t vertexCap, AssPackResult* out) { + memset(out, 0, sizeof(*out)); + out->mode = ASS_PACK_MODE_ATLAS; + out->requiredPages = 1; + out->pageCount = 1; + + // 48 floats per quad x 4 bytes = 192 bytes/quad + const int maxQuads = (int)(vertexCap / 192); + const size_t pageBytes = (size_t)atlasMaxW * atlasMaxH; + int providedPages = (int)(atlasCap / pageBytes); + if (providedPages > ASS_PACK_MAX_PAGES) providedPages = ASS_PACK_MAX_PAGES; + + // Split every image into <= atlasMaxW x atlasMaxH tiles, then pack the tiles. + // tiles[] stays in list order (= blend/painter order for emission); keys[] is + // sorted by height for the single-page pack so it produces tight rows. + int total = 0; + for (ASS_Image* img = image; img != NULL; img = img->next) { + if (img->w > 0 && img->h > 0) { + int cols = (img->w + atlasMaxW - 1) / atlasMaxW; + int rows = (img->h + atlasMaxH - 1) / atlasMaxH; + total += cols * rows; + } + } + out->totalTiles = total; + if (total == 0) return 1; + + PackTile* tiles = (PackTile*)malloc(sizeof(PackTile) * (size_t)total); + TileSortKey* keys = (TileSortKey*)malloc(sizeof(TileSortKey) * (size_t)total); + if (!tiles || !keys) { + free(tiles); + free(keys); + return 0; + } + int n = 0; + for (ASS_Image* img = image; img != NULL; img = img->next) { + if (img->w <= 0 || img->h <= 0) continue; + out->srcPixels += (long long)img->w * img->h; + for (int oy = 0; oy < img->h; oy += atlasMaxH) { + int th = img->h - oy; + if (th > atlasMaxH) th = atlasMaxH; + for (int ox = 0; ox < img->w; ox += atlasMaxW) { + int tw = img->w - ox; + if (tw > atlasMaxW) tw = atlasMaxW; + tiles[n] = (PackTile){.img = img, .ox = ox, .oy = oy, .tw = tw, .th = th, .page = -1, .sx = -1, .sy = -1}; + keys[n] = (TileSortKey){.th = th, .idx = n}; + n++; + } + } + } + int truncated = 0; + + // Pass 1a: height-sorted single page — the common case, minimal packed height + // (byte-identical to the prior single-page packer when the frame fits one page). + qsort(keys, (size_t)n, sizeof(TileSortKey), compareTileKeysByHeightDesc); + int cursorX = 0, cursorY = 0, rowH = 0, packedH = 0, accepted = 0; + for (int i = 0; i < n; i++) { + PackTile* t = &tiles[keys[i].idx]; + if (accepted >= maxQuads) break; + int cx = cursorX, cy = cursorY, rh = rowH; + if (cx + t->tw > atlasMaxW) { + cy += rh; + cx = 0; + rh = 0; + } + if (cy + t->th > atlasMaxH) continue; // doesn't fit a single page + t->page = 0; + t->sx = cx; + t->sy = cy; + cursorX = cx + t->tw; + cursorY = cy; + rowH = (t->th > rh) ? t->th : rh; + if (cy + t->th > packedH) packedH = cy + t->th; + accepted++; + } + + if (accepted == n) { + out->pageHeights[0] = packedH; + out->pageQuads[0] = accepted; + } else { + // Pass 1b: the frame overflows one page. Re-pack in list order, starting a new + // page whenever a tile won't fit the current one. List order keeps the page + // index monotonic in painter order, so each page's quads stay one contiguous run. + for (int i = 0; i < n; i++) { + tiles[i].page = -1; + tiles[i].sx = -1; + tiles[i].sy = -1; + } + int requiredPages = 1; + int page = 0, cx = 0, cy = 0, rh = 0, placed = 0; + for (int i = 0; i < n; i++) { + PackTile* t = &tiles[i]; + if (cx + t->tw > atlasMaxW) { + cy += rh; + cx = 0; + rh = 0; + } + if (cy + t->th > atlasMaxH) { + page++; + cx = 0; + cy = 0; + rh = 0; + } + if (page + 1 > requiredPages) requiredPages = page + 1; + if (page < providedPages && placed < maxQuads) { + t->page = page; + t->sx = cx; + t->sy = cy; + if (cy + t->th > out->pageHeights[page]) out->pageHeights[page] = cy + t->th; + out->pageQuads[page]++; + placed++; + } + cx += t->tw; + rh = (t->th > rh) ? t->th : rh; + } + if (requiredPages > ASS_PACK_MAX_PAGES || n > maxQuads) { + // The frame can never fit the paged alpha atlas: its tiles exceed the page + // cap or the vertex budget outright. Flatten instead of dropping the + // painter-order tail (#1868). + free(tiles); + free(keys); + memset(out->pageHeights, 0, sizeof(out->pageHeights)); + memset(out->pageQuads, 0, sizeof(out->pageQuads)); + compositeFrame(image, atlasPixels, atlasCap, pageBytes, vertices, vertexCap, out); + return 1; + } + out->requiredPages = requiredPages; + out->pageCount = (requiredPages < providedPages) ? requiredPages : providedPages; + accepted = placed; + truncated = n - placed; + } + + out->truncated = truncated; + if (accepted == 0) { + free(tiles); + free(keys); + memset(out->pageHeights, 0, sizeof(out->pageHeights)); + memset(out->pageQuads, 0, sizeof(out->pageQuads)); + return 1; + } + + // Clear only the packed rows of each written page. + for (int p = 0; p < out->pageCount; p++) { + memset(atlasPixels + (size_t)p * pageBytes, 0, (size_t)atlasMaxW * out->pageHeights[p]); + } + + // Emit tiles in list order (= libass's painter/blend order), copying each placed + // tile into its page slot and emitting its quad. Monotonic page assignment makes + // each page's quads a contiguous run, matching pageQuads[] for the per-page draw. + int qi = 0; + for (int i = 0; i < n; i++) { + PackTile* t = &tiles[i]; + if (t->page < 0) continue; + ASS_Image* img = t->img; + const int px = t->sx; + const int py = t->sy; + uint8_t* pageBase = atlasPixels + (size_t)t->page * pageBytes; + + for (int y = 0; y < t->th; y++) { + uint8_t* dst = pageBase + (size_t)(py + y) * atlasMaxW + px; + const uint8_t* src = img->bitmap + (size_t)(t->oy + y) * img->stride + t->ox; + memcpy(dst, src, (size_t)t->tw); + } + + const unsigned int c = img->color; + emitQuad( + vertices + (size_t)qi * 48, (float)(img->dst_x + t->ox), (float)(img->dst_y + t->oy), + (float)(img->dst_x + t->ox + t->tw), (float)(img->dst_y + t->oy + t->th), (float)px / (float)atlasMaxW, + (float)py / (float)atlasMaxH, (float)(px + t->tw) / (float)atlasMaxW, (float)(py + t->th) / (float)atlasMaxH, + (float)((c >> 24) & 0xFFu) / 255.0f, (float)((c >> 16) & 0xFFu) / 255.0f, (float)((c >> 8) & 0xFFu) / 255.0f, + (float)(0xFFu - (c & 0xFFu)) / 255.0f); + qi++; + } + + free(tiles); + free(keys); + out->quadCount = qi; + out->atlasWidth = atlasMaxW; + return 1; +} diff --git a/android/libass/src/main/cpp/AssPack.h b/android/libass/src/main/cpp/AssPack.h new file mode 100644 index 00000000..864e9fa6 --- /dev/null +++ b/android/libass/src/main/cpp/AssPack.h @@ -0,0 +1,57 @@ +// Pure-C frame packer behind the JNI atlas render entry point (AssKt.c). +// Kept free of JNI/Android includes so desktop test harnesses can compile the +// exact shipped packing/composite logic against a host libass build. +#ifndef PLEZY_ASS_PACK_H +#define PLEZY_ASS_PACK_H + +#include +#include + +#include "ass/ass.h" + +// Hard cap on atlas pages. 4 pages of a GL-max texture covers the worst real +// frame measured for ordinary typesetting (a 4K-rendered full-screen letter +// needs 3); denser frames (#1868: ~1500 overlapping paint-stroke images whose +// summed area is ~19 pages at 4K) fall back to the RGBA composite below instead +// of dropping tiles. Must match AssAtlasPipelineConfig.MAX_ATLAS_PAGES. +#define ASS_PACK_MAX_PAGES 4 + +// Result modes. ATLAS: one or more ALPHA_8 pages plus per-quad vertices, the +// common fast path. COMPOSITE: the frame's images were flattened CPU-side into +// a single premultiplied RGBA rect (the union bounding box) drawn as one quad — +// memory is bounded by frame area instead of the sum of per-image areas. +#define ASS_PACK_MODE_ATLAS 0 +#define ASS_PACK_MODE_COMPOSITE 1 + +typedef struct { + int mode; // ASS_PACK_MODE_* + // ATLAS: row stride of every page. COMPOSITE: width of the RGBA rect. + int atlasWidth; + int quadCount; + // Tiles dropped for capacity (frame incomplete). Composite output never + // drops content; it reports truncated == totalTiles only in the + // nothing-written grow request state (quadCount == 0). + int truncated; + // Pages of caller buffer capacity this frame needs to render completely. + // When it exceeds the provided capacity the caller grows and re-renders. + int requiredPages; + int pageCount; + int totalTiles; // tiles the frame splits into (caller-side logging) + long long srcPixels; // summed source image area (caller-side logging) + // ATLAS: packed rows per page / quads per page (contiguous vertex runs). + // COMPOSITE: pageHeights[0] = RGBA rect height, pageQuads[0] = 1. + int pageHeights[ASS_PACK_MAX_PAGES]; + int pageQuads[ASS_PACK_MAX_PAGES]; +} AssPackResult; + +// Packs libass's image list for `atlasPixels`/`vertices` (layout documented at +// the JNI entry point in AssKt.c). Never drops content for size: frames that +// cannot fit ASS_PACK_MAX_PAGES alpha pages (or the vertex budget) flatten into +// an RGBA composite; when the provided buffers are too small for either +// representation, requiredPages tells the caller how much to grow before +// re-rendering, and nothing is written. Returns 0 only on allocation failure. +int ass_pack_frame( + ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, int atlasMaxW, int atlasMaxH, float* vertices, + size_t vertexCap, AssPackResult* out); + +#endif // PLEZY_ASS_PACK_H diff --git a/android/libass/src/main/cpp/CMakeLists.txt b/android/libass/src/main/cpp/CMakeLists.txt index 0e20bbd1..fd5e0d55 100644 --- a/android/libass/src/main/cpp/CMakeLists.txt +++ b/android/libass/src/main/cpp/CMakeLists.txt @@ -44,7 +44,7 @@ set_target_properties(ass PROPERTIES IMPORTED_LOCATION "${LIBASS_ARCHIVE}") target_include_directories(ass INTERFACE "${LIBASS_ROOT}/include") -add_library(${CMAKE_PROJECT_NAME} SHARED AssKt.c SurfaceTxProbe.c) +add_library(${CMAKE_PROJECT_NAME} SHARED AssKt.c AssPack.c SurfaceTxProbe.c) add_dependencies(${CMAKE_PROJECT_NAME} libass_prebuilt_${LIBASS_ABI_TARGET}) # HarfBuzz brings C++; link the shared STL that the app already packages. # (SurfaceTxProbe resolves its libandroid/libsync entry points via dlsym, so no extra link.) diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/AssAtlasFrame.kt b/android/libass/src/main/java/com/edde746/plezy/libass/AssAtlasFrame.kt index 18993c65..f67fc8e0 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/AssAtlasFrame.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/AssAtlasFrame.kt @@ -4,33 +4,44 @@ package com.edde746.plezy.libass * Result of a packed-atlas render. The atlas pixel data is stored in the direct ByteBuffer * that was passed into [AssRender.renderFrameAtlas]; the vertex stream is in the other. * - * The atlas may span more than one *page* — a heavy full-screen sign can produce more - * sub-pixels than a single GL-max texture holds. Pages are vertically stacked in the - * atlas ByteBuffer (page `p` at byte offset `p * atlasWidth * atlasMaxHeight`), each its - * own texture, and are drawn in turn. Quads are emitted in libass painter order and page - * assignment is monotonic in that order, so each page's quads form one contiguous run in - * the vertex stream ([pageQuadCounts]); the runner uploads page `p`, then draws its run. + * In the common [MODE_ATLAS] the atlas may span more than one ALPHA_8 *page* — a heavy + * full-screen sign can produce more sub-pixels than a single GL-max texture holds. Pages + * are vertically stacked in the atlas ByteBuffer (page `p` at byte offset + * `p * atlasWidth * atlasMaxHeight`), each its own texture, and are drawn in turn. Quads + * are emitted in libass painter order and page assignment is monotonic in that order, so + * each page's quads form one contiguous run in the vertex stream ([pageQuadCounts]); the + * runner uploads page `p`, then draws its run. + * + * In [MODE_COMPOSITE] the frame was too dense for the paged atlas (its summed image area + * exceeds every page: overlapping paint-stroke signs, #1868) and the native side flattened + * it into one premultiplied RGBA rect of [atlasWidth] × `pageHeights[0]` pixels at the + * start of the atlas ByteBuffer, drawn as the single emitted quad with UVs 0..1. * * Built in Kotlin by [AssRender.renderFrameAtlas] from the int[] header the native * renderer fills (see `writeAtlasHeader` in AssKt.c) — never constructed from JNI, so * the minifier may obfuscate it freely without breaking the native boundary. * * @param atlasWidth atlas row stride in pixels (= the allocated width; same for every - * page; 0 when [changed] == 0) + * page; 0 when [changed] == 0) — or the RGBA rect width in + * [MODE_COMPOSITE] * @param pageHeights packed height (rows worth uploading) of each page; `size` = page count * @param pageQuadCounts quads on each page, contiguous in the vertex stream in this order; * `size` = page count, `sum` = [quadCount] * @param quadCount total quads; the vertex buffer holds [quadCount] * 6 vertices * @param changed libass change flag (0 = no change, 1 = positions, 2 = content) - * @param truncated images dropped because the frame needed more than [requiredPages] - * pages of capacity or exceeded the vertex budget; the frame is - * incomplete but never stale (should be unreachable for real content) - * @param requiredPages pages this frame needs to render completely. When it exceeds - * [pageHeights].size the caller must grow the atlas buffer and - * re-render; the rendered pages are still valid in the meantime. + * @param truncated images dropped because the frame needed more capacity than the + * buffer holds; the frame is incomplete but never stale. Recoverable + * by growing to [requiredPages]; with the composite fallback, + * unrecoverable truncation should be unreachable for real content + * @param requiredPages pages of buffer capacity this frame needs to render completely. + * When it exceeds [pageHeights].size the caller must grow the atlas + * buffer and re-render; the rendered pages are still valid in the + * meantime. * @param hasOutput true when libass reported at least one visible image for this * timestamp, even when [changed] is 0 and the buffers were not * rewritten. false means this timestamp should be blank. + * @param mode [MODE_ATLAS] or [MODE_COMPOSITE]; must match the ASS_PACK_MODE_* + * constants in AssPack.h */ class AssAtlasFrame( val atlasWidth: Int, @@ -40,8 +51,17 @@ class AssAtlasFrame( val changed: Int, val truncated: Int, val requiredPages: Int, - val hasOutput: Boolean + val hasOutput: Boolean, + val mode: Int = MODE_ATLAS ) { + companion object { + /** One or more ALPHA_8 atlas pages, per-quad colors in the vertex stream. */ + const val MODE_ATLAS = 0 + + /** One premultiplied RGBA rect at the start of the atlas buffer, one quad. */ + const val MODE_COMPOSITE = 1 + } + /** Number of atlas pages this frame occupies. */ val pageCount: Int get() = pageHeights.size diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/AssRender.kt b/android/libass/src/main/java/com/edde746/plezy/libass/AssRender.kt index 35fac0e9..6000bc55 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/AssRender.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/AssRender.kt @@ -8,9 +8,9 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) { companion object { - /** Must match MAX_ATLAS_PAGES + the header layout in AssKt.c (`writeAtlasHeader`). */ + /** Must match ASS_PACK_MAX_PAGES + the header layout in AssPack.h/AssKt.c (`writeAtlasHeader`). */ private const val MAX_ATLAS_PAGES = 4 - private const val HEADER_INTS = 7 + 2 * MAX_ATLAS_PAGES + private const val HEADER_INTS = 7 + 2 * MAX_ATLAS_PAGES + 1 @JvmStatic external fun nativeAssRenderInit(ass: Long): Long @@ -175,7 +175,8 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) { changed = header[2], truncated = header[3], requiredPages = header[4], - hasOutput = header[5] != 0 + hasOutput = header[5] != 0, + mode = header[7 + 2 * MAX_ATLAS_PAGES] ) } } diff --git a/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt b/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt index 7f78235e..52b2e783 100644 --- a/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt +++ b/android/libass/src/main/java/com/edde746/plezy/libass/media/widget/AssSubtitleAtlasPipeline.kt @@ -58,9 +58,11 @@ internal object AssAtlasPipelineConfig { * Hard cap on vertically-stacked atlas pages per slot. A frame whose packed * sub-pixels exceed one [ATLAS_PIXEL_BUDGET] texture (a 4K-rendered full-screen * typeset sign) spills into extra pages so nothing is dropped (#1436); the atlas - * buffer grows on demand toward this cap. 4 covers the worst frame measured (a 4K - * letter needs 3); past it tiles are dropped and counted in `truncated`. Must match - * MAX_ATLAS_PAGES in AssKt.c. + * buffer grows on demand toward this cap. 4 covers the worst frame measured for + * ordinary typesetting (a 4K letter needs 3); frames too dense even for that + * (overlapping paint-stroke signs, #1868) flatten into a single RGBA composite + * ([AssAtlasFrame.MODE_COMPOSITE]) instead of dropping tiles. Must match + * ASS_PACK_MAX_PAGES in AssPack.h. */ internal const val MAX_ATLAS_PAGES = 4 @@ -718,8 +720,9 @@ private class AtlasLibassThread( var frame = render.renderFrameAtlas(timeMs, payload.atlasBuf, slots.atlasW, slots.atlasH, payload.vertexBuf) ?: return null // A frame overflows one atlas page only on dense full-screen typesetting. When it - // does, grow this slot's buffer to the pages it needs (capped) and render once more - // — libass's caches make the re-render cheap, and the slot keeps the larger buffer + // does — multi-page atlas or an RGBA composite rect needing more than one page — + // grow this slot's buffer to the pages it needs (capped) and render once more: + // libass's caches make the re-render cheap, and the slot keeps the larger buffer // so the same density never re-grows. The truncated first result is never handed off. if (frame.requiredPages > payload.pageCapacity && payload.pageCapacity < AssAtlasPipelineConfig.MAX_ATLAS_PAGES) { payload.growAtlas( @@ -794,7 +797,8 @@ private class AtlasLibassThread( "releaseLeadMs=${request.releaseLeadNs / 1_000_000} seq=${payload.contentSeq} waitMs=$waitMs budgetMs=$budgetMs " + "libassMs=$lastLibassMs lockWaitMs=${assHandler.render?.lastLockWaitMs} " + "specHit=${outcome.specHit} changed=${payload.frame.changed} output=${payload.frame.hasOutput} quads=${payload.frame.quadCount} " + - "atlas=${payload.frame.atlasWidth}x${payload.frame.atlasHeight} truncated=${payload.frame.truncated}" + "atlas=${payload.frame.atlasWidth}x${payload.frame.atlasHeight} truncated=${payload.frame.truncated} " + + "mode=${payload.frame.mode}" ) } } @@ -1403,15 +1407,19 @@ private class AtlasRenderer(private val assHandler: AssHandler) { } """.trimIndent() + // u_Rgba switches the sampling mode: 0 = ALPHA_8 atlas mask tinted by the per-vertex + // color (the common path), 1 = premultiplied RGBA composite sampled directly + // (AssAtlasFrame.MODE_COMPOSITE). Both end premultiplied, matching the blend state. private val fragmentShaderCode = """ precision mediump float; varying vec2 v_TexCoord; varying vec4 v_Color; uniform sampler2D u_Texture; + uniform float u_Rgba; void main() { - float mask = texture2D(u_Texture, v_TexCoord).a; - float alpha = v_Color.a * mask; - gl_FragColor = vec4(v_Color.rgb * alpha, alpha); + vec4 texel = texture2D(u_Texture, v_TexCoord); + float alpha = v_Color.a * texel.a; + gl_FragColor = mix(vec4(v_Color.rgb * alpha, alpha), texel, u_Rgba); } """.trimIndent() @@ -1423,11 +1431,18 @@ private class AtlasRenderer(private val assHandler: AssHandler) { private var allocatedPages = 0 private var vertexBufferId = 0 + // Texture for MODE_COMPOSITE frames (premultiplied RGBA rect); allocated lazily on + // the first composite frame and re-specced when the rect outgrows it. + private var rgbaTexId = 0 + private var rgbaAllocW = 0 + private var rgbaAllocH = 0 + private var aPosition = 0 private var aTexCoord = 0 private var aColor = 0 private var uTexture = 0 private var uSurfaceSize = 0 + private var uRgba = 0 private var atlasAllocatedW = 0 private var atlasAllocatedH = 0 @@ -1477,6 +1492,7 @@ private class AtlasRenderer(private val assHandler: AssHandler) { aColor = glProgram.getAttributeArrayLocationAndEnable("a_Color") uTexture = glProgram.getUniformLocation("u_Texture") uSurfaceSize = glProgram.getUniformLocation("u_SurfaceSize") + uRgba = glProgram.getUniformLocation("u_Rgba") GLES20.glActiveTexture(GLES20.GL_TEXTURE0) GLES20.glUniform1i(uTexture, 0) @@ -1527,11 +1543,22 @@ private class AtlasRenderer(private val assHandler: AssHandler) { GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, stride, 0) GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, stride, 8) GLES20.glVertexAttribPointer(aColor, 4, GLES20.GL_FLOAT, false, stride, 16) + GLES20.glActiveTexture(GLES20.GL_TEXTURE0) + if (frame.mode == AssAtlasFrame.MODE_COMPOSITE) { + // The whole frame is one premultiplied RGBA rect (atlasWidth × pageHeights[0]) + // at the start of the atlas buffer, drawn as the single emitted quad. + GLES20.glUniform1f(uRgba, 1f) + bindCompositeTexture() + if (!reuseUploads) uploadComposite(payload.atlasBuf, frame.atlasWidth, frame.pageHeights[0]) + GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, quadCount * 6) + return + } + + GLES20.glUniform1f(uRgba, 0f) // Each atlas page is its own texture; its quads are one contiguous run in the // stream (page assignment is monotonic in painter order). Upload + draw each in // turn, which reproduces the libass blend order across pages. - GLES20.glActiveTexture(GLES20.GL_TEXTURE0) var quadOffset = 0 for (p in 0 until frame.pageCount) { val pageQuads = frame.pageQuadCounts[p] @@ -1545,6 +1572,48 @@ private class AtlasRenderer(private val assHandler: AssHandler) { } } + /** Generates (once) and binds the RGBA composite texture. */ + private fun bindCompositeTexture() { + if (rgbaTexId == 0) { + val tex = IntArray(1) + GLES20.glGenTextures(1, tex, 0) + rgbaTexId = tex[0] + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, rgbaTexId) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR) + rgbaAllocW = 0 + rgbaAllocH = 0 + } else { + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, rgbaTexId) + } + } + + /** Uploads the composite RGBA rect from the start of the stacked atlas buffer into + * the bound composite texture. GLES2 has no UNPACK_ROW_LENGTH, so a sub-image + * update is only stride-correct at the allocated width; otherwise re-spec. */ + private fun uploadComposite(atlasBuf: ByteBuffer, width: Int, height: Int) { + if (width <= 0 || height <= 0) return + atlasBuf.clear() + atlasBuf.limit(width * height * 4) + atlasBuf.position(0) + if (width == rgbaAllocW && height <= rgbaAllocH) { + GLES20.glTexSubImage2D( + GLES20.GL_TEXTURE_2D, 0, 0, 0, width, height, + GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, atlasBuf + ) + } else { + GLES20.glTexImage2D( + GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, + width, height, 0, + GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, atlasBuf + ) + rgbaAllocW = width + rgbaAllocH = height + } + } + /** Uploads page [page]'s packed rows from the stacked atlas buffer into the * currently-bound page texture. */ private fun uploadPage(atlasBuf: ByteBuffer, page: Int, atlasW: Int, pageH: Int) { @@ -1578,6 +1647,13 @@ private class AtlasRenderer(private val assHandler: AssHandler) { atlasTexIds.fill(0) allocatedPages = 0 } + if (rgbaTexId != 0) { + val tex = intArrayOf(rgbaTexId) + GLES20.glDeleteTextures(1, tex, 0) + rgbaTexId = 0 + rgbaAllocW = 0 + rgbaAllocH = 0 + } if (vertexBufferId != 0) { val buf = intArrayOf(vertexBufferId) GLES20.glDeleteBuffers(1, buf, 0)