Skip to content

J2C fetches decode at the level their bytes reach, not the level requested - #552

Merged
RyeMutt merged 3 commits into
developfrom
rye/j2c-byte-targets
Oct 22, 2026
Merged

RyeMutt merged 3 commits into
developfrom
rye/j2c-byte-targets

Conversation

@RyeMutt

@RyeMutt RyeMutt commented Oct 22, 2026

Copy link
Copy Markdown
Member

Problem

With Maximum LOD resolution (RenderMaxTextureResolution) at 1024, 2048 textures stayed very blurry after a relog until the setting was toggled up and back down.

Cause

A fetch for discard 1 and above asked for calcDataSizeJ2C's estimate, which is based on dimensions only. For a 2048 at discard 1 that is 128 KB, about 9% of a median 1.39 MB file.

CACHE_POST and the HTTP completion then recorded the requested discard as loaded. So a reduce-1 decode of the first few quality layers counted as discard 1, and nothing fetched the rest. The cap holds 2048s at discard 1, so they never recovered. Toggling to 2048 asks for discard 0, which always fetches the whole file.

This started with ed0162a, which replaced LL's inflated curve (effectively the whole file for discards 0–2) with the old sqrt estimate. The same flaw softened every texture whose desired discard stays above 0, cap or no cap.

Measured on a 52k-texture cache:

  • 99.4% of the files are LRCP, single tile, single tile-part, 5 levels.
  • 17% have a single quality layer, which the estimate cuts inside a low resolution.
  • At discard 1, the estimate decoded at 43.9 dB median / 29.9 dB p10 (2048 multi-layer) and 23.9 dB (2048 single-layer), against a decode of the whole file.

Change

  • ALJ2CLayout reads SIZ, COD and the first SOT without OpenJPEG. Total length = SOT offset + Psot + EOC, and all of it is in the first 600 bytes a fetch reads.
  • Byte targets. LLImageJ2C::calcDataSize targets the codestream length times the fraction that decodes within 45 dB of the full decode at that level, by longer side. For 2048s that is 65/30/12/5/3% at discards 1–5. The fractions were calibrated on half the cache and validated on the other half: at least 93.5% of each class decodes within 45 dB. The dimensions-only estimate stays for a codestream whose header is not in hand.
  • Fetcher:
    • It sizes requests from the image's own layout and keeps that layout for the texture's later requests.
    • mLoadedDiscard is the finest level the bytes reach (calcLoadedDiscard), so a short response decodes as a preview and the existing DONE -> INIT loop fetches the rest.
    • A cache entry that holds half the target or more is read whole.
    • A failed network fetch with data in hand decodes what it holds and finishes (mFetchFailed), rather than retrying every cycle.
  • Removed: TextureReverseByteRange and TextureNewByteRange. The first decoded at half the estimate, which brought the softness back.
  • Removed: the SOT-walker byte seeding in LLImageJ2COJ::getMetadata. It mapped every SL texture's discard levels to the whole file, and it never ran on the fetch path.

The matching registry change, openjpeg 2.5.4#31 without opj_j2k_walk_tile_part_ends, is pushed to alchemy-registry (dc47644). Nothing here calls the walker, so this builds against #30 or #31, and the baseline bump can come separately.

Testing

  • alj2clayout_test covers:
    • the parser over the viewer's encoder and OpenJPEG's (layered, single-layer, four tiles, every truncation of the header, a missing SOT);
    • decodes at every target, which pass 45 dB where the old estimate gives 30–36 dB at discard 1.
  • With calcDataSize put back on the old estimate, the tests fail at every level.
  • ctest -L llimage passes 6/6, and the viewer builds (Windows, RelWithDebInfo).
  • Still to do in-world: the fetch-worker changes have no unit test (the worker needs a region). Relog with the 1024 cap and check that 2048s are sharp without toggling, and that no texture sits at its desired discard with fewer bytes than its target in the texture console.

🤖 Generated with Claude Code

RyeMutt and others added 3 commits October 22, 2026 12:00
calcDataSizeJ2C estimates a discard level's bytes from the texture's dimensions alone: 128 KB for a 2048 at
discard 1, about 9% of a median 1.39 MB file. Second Life's LRCP codestreams hold only their first quality
layers in that much, and its single-layer ones are cut inside a low resolution, so a decode at the level comes
out soft or very blurry: 2048 multi-layer at discard 1 measured 43.9 dB PSNR at the median and 29.9 dB at p10
against the decode of the whole file, single-layer 23.9 dB.

ALJ2CLayout reads SIZ, COD and the first SOT without OpenJPEG. A codestream of one tile in one tile-part, the
shape all 52,000 complete entries of a texture cache have, ends at that SOT's offset plus Psot plus the EOC,
and all of it sits in the first 600 bytes a fetch reads. LLImageJ2C parses it whenever setData or appendData
changes its data (LLImageFormatted::onDataChanged) and in updateData, and calcDataSize and
calcDiscardLevelBytes follow it: the target for discard d is the codestream's length times the fraction that
decodes within 45 dB of the whole file's decode at that level, by the texture's longer side. The quality
layers' byte tiers are fixed, so a small file needs a larger share: 90% at discard 1 up to 128 pixels, 65%
from 1024. The sizes-only estimate stays for a codestream whose headers are not in hand.

The fractions were calibrated on half of that cache and checked on the other half: at its targets every class
of longer side and layer count decodes within 45 dB for 93.5% of its files or more. Against the starting
table of 75/50/25/10/10% for every size, 2048s take 65/30/12/5/3%.

TextureReverseByteRange decoded at half the estimate, which put back the softness the targets remove, and
TextureNewByteRange chose between the estimate's two formulas. Both settings go, with LLImage's copies: the
fractions carry the quality bar, and the estimate is always the smaller formula.

Tests: the parser over the viewer's encoder and OpenJPEG's (layered, single-layer, four tiles, every cut of
the header, a missing SOT), and decodes at every target of layered and single-layer codestreams, which pass
45 dB where the sizes-only estimate gives 30-36 dB at discard 1. With calcDataSize back on the estimate they
fail at every level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the level its bytes reach

With RenderMaxTextureResolution at 1024, 2048 textures stayed blurry after a relog until the setting was
toggled. The fresh worker that asked for discard 1 read calcDataSizeJ2C's estimate from the cache (128 KB of a
complete 1.39 MB entry), and CACHE_POST, like the HTTP completion, recorded the requested discard as loaded. A
decode of the first quality layers at reduce 1 therefore counted as discard 1, the DONE -> INIT loop had
nothing left to fetch, and the cap, which pins 2048s at discard 1, made it permanent. Toggling to 2048 asked
for discard 0, which fetches everything.

The worker copies the codestream's layout from its formatted image once the headers are in (updateLayout)
and keeps it for the texture's later requests, since endWork() lets go of the image after each one. It sizes
every request for discard 1 and up from it, in createRequest and setDesiredDiscard as well as in the running
request; discard 0 still asks for everything. When the headers arrive with a cache read the estimate sized and
the entry filled that read, it reads on from the entry instead of decoding what it has. CACHE_POST and the
HTTP completion set mLoadedDiscard to the finest level the bytes reach and never finer than the request
(calcLoadedDiscard), so a response short of the target decodes as a preview and the DONE -> INIT loop fetches
the rest. DONE weighs that level as well as the decode's, which a codestream with fewer resolutions than the
level loaded makes finer than the bytes support. A cache read that needs half the codestream or more takes all
of it: local disk is cheap next to a second read.

A request whose network fetch fails with data in hand decodes what it holds and finishes, as before
(mFetchFailed). Without that, a preview short of the target made DONE ask again after every failure, with no
backoff for ordinary textures: a decode, a cache read and a request per cycle for as long as the server failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ends

The SOT walker mapped tile-part ends to discard levels on the assumption of one tile-part per resolution.
Every Second Life texture has one tile-part, so every level got the whole file, and a partial walk mapped
discard 0 to the last tile-part walked rather than the end of the codestream. LLImageJ2C takes its targets
from ALJ2CLayout now. getMetadata's guess at mDiscardLevel, which updateData overwrote, goes with it.

The registry's openjpeg 2.5.4#31 drops opj_j2k_walk_tile_part_ends; nothing here calls it, so the viewer
builds against either port version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 47d8ef75-a155-4237-bae7-1a616866d1b4
📥 Commits

Reviewing files that changed from the base of the PR and between 85b8ea2 and a6ce80e.

📒 Files selected for processing: 15
  • indra/llappearanceutility/llappappearanceutility.cpp
  • indra/llimage/CMakeLists.txt
  • indra/llimage/alj2clayout.cpp
  • indra/llimage/alj2clayout.h
  • indra/llimage/llimage.cpp
  • indra/llimage/llimage.h
  • indra/llimage/llimagej2c.cpp
  • indra/llimage/llimagej2c.h
  • indra/llimage/tests/alj2clayout_test.cpp
  • indra/llimagej2coj/llimagej2coj.cpp
  • indra/llkdu/tests/llimagej2ckdu_test.cpp
  • indra/newview/app_settings/settings.xml
  • indra/newview/llappviewer.cpp
  • indra/newview/lltexturefetch.cpp
  • indra/newview/skins/default/xui/pt/settings_comments.xml
💤 Files with no reviewable changes: 2
  • indra/newview/skins/default/xui/pt/settings_comments.xml
  • indra/newview/app_settings/settings.xml

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Summary

Summary by CodeRabbit

  • Improvements

    • JPEG 2000 textures now use codestream layout information to better estimate the data needed at each detail level, supporting more accurate progressive loading from cached or downloaded data.
    • Updated image data is now reflected in texture layout calculations.
  • Bug Fixes

    • When a texture download fails, available data can still be decoded instead of triggering further requests.
  • Settings

    • Removed the texture byte-range configuration options.

The change adds JPEG 2000 codestream layout parsing and uses layout-derived byte and discard calculations in image handling and texture fetching. It removes configurable byte-range settings and changes how cache and HTTP data contribute to loaded discard levels.

Changes: JPEG 2000 layout and texture fetching

Layer / File(s) Summary
Parse codestream layout and calculate byte targets
indra/llimage/alj2clayout.*, indra/llimage/tests/alj2clayout_test.cpp, indra/llimage/CMakeLists.txt
Adds JPEG 2000 header parsing and layout-based size calculations. Tests cover valid and malformed headers, incremental data, byte targets, and decode quality.
Integrate layout with image handling
indra/llimage/llimage.*, indra/llimage/llimagej2c.*, indra/llimagej2coj/llimagej2coj.cpp, indra/llkdu/tests/llimagej2ckdu_test.cpp, indra/llappearanceutility/llappappearanceutility.cpp, indra/newview/llappviewer.cpp, indra/newview/app_settings/settings.xml, indra/newview/skins/default/xui/pt/settings_comments.xml
Adds a data-change hook and stores parsed layouts in J2C images. Image sizing uses layout calculations when valid. Removes byte-range settings and decoder-derived discard metadata.
Use layout in cache and network fetching
indra/newview/lltexturefetch.cpp
Workers use layout-derived byte and discard calculations for cached and HTTP data. They track cache-read progress and fetch failures, and update completed-work retry conditions.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant TextureFetchWorker
  participant CacheEntry
  participant HTTP
  participant LLImageJ2C
  TextureFetchWorker->>CacheEntry: Read cached texture bytes
  CacheEntry->>TextureFetchWorker: Return cached bytes
  TextureFetchWorker->>LLImageJ2C: Update image data and inspect layout
  TextureFetchWorker->>HTTP: Request additional texture bytes
  HTTP->>TextureFetchWorker: Return response bytes
  TextureFetchWorker->>LLImageJ2C: Append image data and inspect layout
Loading

Suggested reviewers: marchcat

Merge Risk: ⚪ Minimal · up to a6ce8

No identified issue blocks merging after normal checks. In-world texture validation remains pending.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 47.27% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 55 functions across 12 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly summarizes the main JPEG 2000 fetch behavior change: decoding follows the level supported by received bytes rather than the requested level.
Description check Passed The description gives detailed problem, cause, implementation, testing, and remaining validation context. It does not include the template's Related Issues, Checklist, or Additional Notes sections, bu…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description gives detailed problem, cause, implementation, testing, and remaining validation context. It does not include the template's Related Issues, Checklist, or Additional Notes sections, but the core information is complete.

Full details: Docstring Coverage

Explanation

Docstring coverage is 47.27% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 55 functions across 12 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit watched the headers parse,
Then counted bytes across the grass.
The cache sent crumbs; the stream sent more,
Each discard level found its score.
The rabbit thumped and hopped away,
While textures loaded clear as day.

Comment @coderabbitai help to get the list of available commands.

@RyeMutt
RyeMutt merged commit 5c867a3 into develop Oct 22, 2026
17 checks passed
@RyeMutt
RyeMutt deleted the rye/j2c-byte-targets branch October 22, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant