Skip to content

Integrate #758, #622 and #716: upscaled textures, bounded bitmap uploads, Enhanced look - #764

Merged
nitoygo merged 13 commits into
sven-n:mainfrom
nitoygo:integration/texture-prs
Oct 17, 2026
Merged

nitoygo merged 13 commits into
sven-n:mainfrom
nitoygo:integration/texture-prs

Conversation

@nitoygo

@nitoygo nitoygo commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Merges #758, #622 and #716 into main together, with their conflicts resolved once. Each PR is a merge commit here with its author's commits unchanged, and the resolution is in that merge commit. Merge this with "Create a merge commit" (not squash or rebase): GitHub then marks the three PRs as merged.

Order: #758 (follow-up to #718, merged cleanly), then #622, then #716, which contains #622 as its first commit.

Which design each area follows

Where two PRs, or a PR and main, had implemented the same thing, this table says which implementation the integration keeps and what it replaces. Each feature itself is kept.

Area Kept Replaced
Recording draws (skinned meshes, shadows, terrain merging) Main's: GPU mesh cache (#743), GPU body shadows (#749), merged terrain draws (#751) #716's per-draw skinned vertex upload, which predates the mesh cache. Enhanced lighting now goes through main's cached draw.
Frame structure #716's multi-pass replay (scene, bloom and UI passes, multisampling, single-sample pipelines), which Enhanced needs Main's single-pass replay. Main's profiler scope and terrain-height checks are carried into it.
Editor drawing Main's own pass after RmlUi #716's editor marker inside the replay
Texture upload #622's bounded uploader (fixed staging slots, four shared samplers, testable GPU calls), extended with #716's mip-level packing and row bands for large images #718's per-texture upload and per-texture sampler
Texture filtering #716's shared anisotropic samplers and [Render] Anisotropy #718's fixed 16× anisotropy
Mip chains Both: #718's GPU-built mips for opaque textures, #716's alpha-preserving CPU chain for textures with alpha #716's CPU chain for every texture
CPU pixel memory #758: pixels are released after upload #716's assumption that pixels stay in memory. Switching to Enhanced re-reads the file.
Upload failures Mosch0512's review of #622: one failed upload, or one lost slot #622's single error disabling uploads for the session
What Classic means #716's rule for original textures: unchanged, mips never sampled Exception: upscaled textures are always mipmapped (#718's intent), at [Render] Anisotropy
Upscaled art #718's behavior: marker, original-size textures, mipmaps (none)
Enhanced as a data-driven preset, off by default #715 / #716 (none)

In short: rendering performance follows main, texture resource management follows #622 together with #758's memory handling, the visual features follow #716, and upscaled art keeps #718's behavior through #622's uploader and #716's samplers.

What the resolutions decide

#622 with the upscaled textures (#718, #758)

#716 with the upscaled textures

#716 with the renderer changes on main

Fixes on top

Test plan

  • RelWithDebInfo build of every target (Windows, MSVC).

  • ctest: all 853 pass. Two or three rml-themed-view UI tests fail only when run in parallel (-j8) and pass on their own; they don't touch this code.

  • Opt-in GPU upload test (MU_RUN_GPU_UPLOAD_TESTS=1) on Direct3D 12 and Vulkan, including a 2048×1030 banded upload with GPU mips.

  • Classic against main, same spot in Noria, 1280×720, VSync off, Vulkan ($glstats, $details):

    main this branch
    Terrain draws / KB 385 / 354 385 / 354
    Objects draws 467 467
    Chars draws / KB 142 / 112 142 / 112
    Draws submitted 1234 1236
    Pipeline binds 261 262
    Replay 0.54 ms 0.55 ms
    Average FPS 286 301

    The FPS difference is within run-to-run variation.

  • In game with nolt's upscaled pack: about 1,880 mipmapped textures, about 2 GB on the GPU, no load errors. Switched to Enhanced while playing: 4× anti-aliasing, alpha-to-coverage, bloom, light mood and GPU body shadows all draw; about 275 FPS with the pack, 1.2 ms replay including the bloom pass. Switched back to Classic.

  • Original data: switching to Enhanced rebuilt 89 world textures as they were drawn, 12 of them with alpha (grass tiles, flowers) re-read from file into the CPU chain, with no re-read failures.

  • Quitting from the login screen and from the world exits cleanly.

  • Metal, Linux and D3D12 in game: not run here (in game was Vulkan only).

🤖 Generated with Claude Code

alandarev and others added 13 commits October 15, 2026 12:19
Use three persistent fenced staging buffers and four device-owned bitmap samplers instead of per-image allocations. Preserve whole-image uploads and existing decoder limits, check submission failures, and keep borrowed sampler ownership explicit.

Add decoded-loader, failure/cleanup, truncated OZT and non-presenting GPU regression coverage, including intermediate readbacks and opt-in Vulkan validation.

Refs sven-n#620
Adds a second look the player can switch to, while the classic look stays
the default and draws exactly as before. `[Render] Look=Classic|Enhanced`
(the "Enhanced graphics" checkbox in the options window, `$look classic|
enhanced` in the chat) switches five features and anti-aliasing on or off
together:

- texture filtering: coverage-preserving RGBA8 mip chains and anisotropic
  sampling for world textures (terrain, objects, models), built on load or
  on first use; the chains upload through the fixed staging slots of the
  bitmap owner, as many levels per slot as fit;
- per-pixel model lighting and fog (skinned_lit, basic_textured_fog):
  wrapped diffuse, rim and per-item specular (`material.specular` in the
  item-model JSON), a highlight roll-off that keeps burnt-out tops shaded;
- dynamic model lights: the lights the game already adds to the terrain
  (candles, fires, spells) also light characters and monsters, through a
  per-frame light history with fade, range and strength limits;
- a per-map light mood: deeper shade, a sun direction and model brightness
  per map, scaled from the baked terrain light;
- scene post-processing: a selective bloom (bright effects, not the UI) and
  per-map colour grading tables;
- multisample anti-aliasing, with alpha-to-coverage for alpha-tested cutouts,
  at `[Render] Antialiasing` samples (default 4, `$msaa off|2|4|8`); Classic
  always renders one sample per pixel.

Every value of the preset lives in two data files with per-map overrides
(`Data/Render/Look.json`, `Data/Maps/LightMood.json`); without them the
built-in values apply. Builds with the control socket or the editor tune
any value live (`$look set <name> <value>`, `$look save`), switch one
feature for the session (`$texfilter`, `$lighting`, `$dynlights`,
`$lightmood`, `$post`) and the DevEditor gets a Look panel. The anisotropy
level (`[Render] Anisotropy`) is a setting of its own.

With RmlUi: the checkbox is a row on the options window's Video tab (both
themes; styled for `legacy`). Multisampling covers the frame's main pass
only; the passes that render into single-sample targets (offscreen
captures, RmlUi render targets, the post-RmlUi pass, flushed command
ranges) replay with single-sample twins of the multisampled pipelines,
built and released with them. Models drawn into RmlUi render targets
(item previews, the character-creation preview) keep Classic sampling.

Shaders are regenerated for all three backends with the project's
commands; Vulkan is tested, Metal and Direct3D 12 compile but have not
been run.
The Enhanced preset's values, chosen by comparing candidates side by side
in towns, dungeons and fights across the Season 6 maps:

- `Data/Render/Look.json`: model lighting (diffuse, rim, specular, wrap,
  highlight roll-off), dynamic light gain, range and caps for effect
  lights, bloom and grading; Icarus keeps a weaker bloom for its clouds;
- `Data/Maps/LightMood.json`: the default mood and per-map moods (sun,
  shade, brightness) so that dark maps stay readable;
- `Data/Grading/World2.png`: Dungeon's colour grading table;
- `material.specular` 0.6 on the metal armour sets of the Dark Knight,
  Magic Gladiator and Dark Lord (115 parts); cloth-heavy sets stay matte,
  since specular applies to the whole model.

Tests pin the shipped values and the list of shiny armour parts.
`docs/enhanced-look.md` describes the two looks, the settings, which file
holds each piece of the Enhanced preset (model lighting, dynamic lights,
bloom and grading, light mood, specular armour), per-map overrides, and the
developer commands that tune values live and save them. `light-mood.md` and
`post-processing.md` go deeper into those two pieces; the options window
page names the "Enhanced graphics" checkbox.
The game draws two textures through their CPU pixels: the guild mark
(BITMAP_GUILD, CreateGuildMark) and the castle owner's mark on the flag
in Valley of Loren (BITMAP_INTERFACE_MAP, CreateCastleMark). Only a
comment kept upscaled art away from them, and it named the wrong ones.
An upscaled Object31/Flag.OZT lost its pixels after the upload and the
castle mark wrote through a null pointer.

They now load through LoadCanvasBitmap, which asks the loader to keep
the pixels. The mark writers are laid out for the original size, so an
upscaled file loaded into a canvas is shrunk back to that size, each
pixel the average of its block, and uploaded without mips.
CreateCastleMark also checks that the bitmap is loaded, like
CreateGuildMark.
Apart from the two canvases, nothing reads a texture's pixels after the
upload, so the loader now frees them for every texture, originals
included, not only for upscaled ones.

Texture Memory in the debug console counted those CPU copies, so it
went down when a pack was installed and would now read almost zero. It
counts the base level of each uploaded texture instead.
MAX_WIDTH and MAX_HEIGHT (1024) were checked against the stored image
before the marker was read, so a 4x file of any original over 256 px,
or a 2x file of one over 512 px, did not load. The log blamed TurboJPEG
or an invalid OZT format.

The loader now reads the marker first and accepts a marked image up to
its scale times the limit; the power-of-two padding uses the same
bound. A rejected image logs its size and the limit.
Resolved against the upscaled textures on main (sven-n#718) and sven-n#758:

- The loader moved to GlobalBitmapLoad.cpp; it now carries sven-n#718/sven-n#758's
  marker scale, original-size Width/Height, premultiplied RmlUi images,
  canvases that keep their pixels, the released CPU copy and the
  base-level texture memory count.
- The uploader keeps its three 4 MiB slots and stages an image larger
  than one slot (marked files up to 4x the 1024 px limit) in bands of
  rows, one submission each.
- Mipmapped uploads: the GPU builds the chain after the last band, and
  two more shared samplers (trilinear, anisotropic, repeat/clamp) serve
  them, so upscaled textures no longer create a sampler each.
- Tests: six shared samplers, the wider size limit, banded staging with
  mips, and a marked file in the loader (mipmapped, original size,
  released pixels; a canvas shrunk and kept).
Resolved against main's renderer and the upscaled textures (sven-n#718, sven-n#758):

- Skinned draws: Enhanced lighting (its pipelines, alpha-to-coverage, the
  world sampler, eye position, specular and world-space rows) moved into
  RecordSkinnedDraw, the path of the GPU mesh cache; the per-draw vertex
  upload of sven-n#716's old path is gone. A skinned draw's colour is one
  per-instance value, so it gives the draw's alpha.
- Frame replay: sven-n#716's passes (frame, post-processed frame, continued
  flush) keep main's replay profiler scope and main's editor pass after
  RmlUi; sven-n#716's editor marker inside the replay is dropped.
- The GPU body-shadow pipelines join the single-sample twins, so shadows
  still draw outside the multisampled main pass; they take no coverage.
- Upload: sven-n#716's mip levels and the row bands share one staging loop; a
  texture can also have the GPU build its chain. The uploader is back to
  four shared samplers.
- Textures: upscaled art is always mipmapped and sampled through sven-n#716's
  anisotropic samplers, in both looks. Opaque images get GPU-built mips,
  images with alpha sven-n#716's coverage-preserving CPU chain, at load and on
  the switch to Enhanced. The pixels are released after the upload, so
  that switch reads the file again.
- Shader checks: both sides' shaders in the validation lists; the raw
  bone buffer check covers the skinned vertex shaders only.
- Docs: the upload staging note, and Anisotropy applies to every
  mipmapped texture.
Texture creation, mapping, acquiring and the copy pass fail before the
slot is in use, so only that upload fails now. A failed submit or fence
wait leaves the GPU's use of its slot unknown: that slot is taken out
of service and uploads go on through the others; the uploader faults
only when no slot is left. A faulted uploader keeps the error that
faulted it instead of overwriting it with every rejection, and an
uninitialized one says so rather than "wrong thread". From the review
of sven-n#622.
…eased

The static Bitmaps unloads its images again at exit, after the
renderer has shut down. ReleaseFilteredSamplers then asked the renderer
for its device, and GetDevice logs a warning without one, through a
logger that is already destroyed: every quit crashed. Without a sampler
to release it now returns before asking.
The opt-in GPU upload test now also uploads a 2048x1030 image, larger
than one staging slot, with the GPU building its mips, and reads back
the base level.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants