diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3bcc3b5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,120 @@ +# Changelog + +## 0.3.1 +- Fix: 0.3.0 crashed at launch when a saved pipeline cache existed (vesuvio-cache/, a few MB after the first + session). The saved data was copied onto LWJGL's small per-thread stack; it now goes in heap memory that is freed + right after. Any failure while loading the cache (also an Error) now starts the game without the cache instead of + stopping it, and saved caches over 64 MB are ignored. Existing cache files are read normally; nothing to delete. +- The Vulkan instance extension list read for the MoltenVK settings no longer uses LWJGL's stack either (it grows + with every installed layer). + +## 0.3.0 +- GPU Pass Timers (Frame page, off, live): a diagnostic for "where does the GPU time go". Vulkan timestamps + (vkCmdWriteTimestamp2) right before and right after every render pass, always outside the pass, and around each + command buffer of the frame, one query pool per frame in flight, reset from the CPU and read after the game's own + wait for that frame (never waits). Passes are grouped by label, so vanilla's and Etna's passes show by name. F3 + shows the GPU frame time and the five slowest passes; the benchmark adds a "GPU pass timers" step (user settings + plus the timers; its "vs on" is the cost of measuring) and logs a "GPU passes" table: top 15 passes with ms and + share, other passes, uploads, and time between passes. Off: no query written, no pool. +- Keep Framebuffers (Frame page, on): the Vulkan backend made a new VkFramebuffer for every render pass and destroyed + it after the frame. Each one is now kept, keyed by render pass, attachment views and size, and reused; it goes when + one of its views is closed (deferred like vanilla's), when more than 128 are kept, or with the device. Counters + "framebuffers reused" and "framebuffers kept". +- Direct Vertex Writes (Frame page, on; unified memory only, read from the GPU's memory heaps): the vertex and index + buffers of entities, particles and the GUI are made host-visible and written directly, skipping the staging buffer, + 2 to 4 GPU copies and their barriers a frame. Reuse still goes through the game's own per-frame buffer fences, so a + buffer is never rewritten while a frame in flight reads it. Counter "vertex copies skipped". +- No Metal Argument Buffers (Frame page, off, restart, macOS only): a benchmark knob that passes + MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS=0 to MoltenVK through the same layer settings as the other MoltenVK options. +- Leaving or changing worlds no longer keeps the old world's sections, meshes and draw lists alive in the terrain + draw-list cache, nor the last render pass's targets in Clear Fusion (also let go at every submit). +- Treat Hidden Window as Minimized: the hidden/occluded flag must now last half a second of loops (each loop counting + at most 100 ms, events polled every loop) before the frame rate drops, so a window SDL briefly reports as occluded + after a long stall (Wayland while a world loads) no longer runs at 1 fps for a moment. +- Less per-frame garbage: Clear Fusion makes its list of clears to send only when there is one (before, every render + pass and texture copy made one while the GUI's depth clear waited), and a terrain draw-list reuse copies the kept + section list into vanilla's in one go instead of adding thousands of entries one by one. +- Compact Terrain Vertices (World page, off, restart): chunk meshes in a 20-byte vertex instead of vanilla's 28 + (half-float positions, 16-bit unorm texture coordinates; colour and light unchanged): about 29% less terrain + graphics memory, upload bandwidth and GPU vertex fetch, most with Etna's 3072 shadow pass. No shader change: the + GPU's vertex fetch converts the formats. Blocks on the 1/16 grid draw exactly as before; other positions round + towards the middle of their 1/16 cell (never onto a block face, so water next to glass does not flicker), texture + coordinates round towards the middle of their quad (no sprite bleeding). A block atlas over 8192 switches the next + start to float texture coordinates (24 bytes). Works with Etna's terrain and shadow pipelines and with Fabric API's + Indigo renderer; face culling and the translucent sort read the half positions. Counter "terrain vertex bytes". +- Faster Entity Models (Entities page, on): a model box's 8 corners are transformed once instead of its 24 vertices + (each corner is shared by 3 faces), through the same JOML call with the same inputs, so every vertex written is + bit for bit vanilla's (checked offline against the game's own Cube.compile on 1.2 million random boxes and poses, + mirrored and negative-scale ones included). Normals, vertex order and the 11-argument BufferBuilder path are + unchanged. A box's corner layout is kept on it and checked against its faces before each use. Counter + "model cubes fast". +- Cache Sign Text (Entities page, on): each sign line's width and glyph layout (plain, and the 8-way outline of + glowing text) are kept and replayed with the current pose instead of being redone every frame. Kept per sign line + and checked against the font, position, colour, shadow and outline on every use; dropped on resource reload, font + option changes and world changes; at most 4096 lines. Obfuscated text and sprite/player-head fonts are never kept. + Counter "sign text cache hits". +- Vesuvio's own per-vertex/per-draw overhead trimmed: hooks that run per vertex, quad, block, entity, particle, + section or draw no longer allocate (no CallbackInfo, no wrapped-call argument arrays or boxing, no local + wrappers): compact vertices, occlusion and fog culling, the face-culling draw cut, redundant binds, the model fast + path, leaf culling, animated-texture collection, the resort throttle, transfer barriers and item frame/sign + culling. The chunk scheduler reuses vanilla's region cache, and the terrain draw-list reuse no longer makes a + throwaway copy of the kept section list. Same pictures, same counters; no new settings. + +## 0.2.0 +- Clear Fusion (Rendering page, on): a texture cleared through the command encoder and then drawn into is now + cleared by the render pass as it opens (LOAD_OP_CLEAR, same value, same pixels) instead of by a separate clear + that writes the whole image and has the pass read it back; the GUI's dead depth clears are dropped; post chain + targets no input reads as depth get no depth texture. Works for any mod's passes (Etna's post chains included). + Counters: clears fused, dropped, split. +- Memory check (Memory page, F3): measures how much memory the game really needs and tells you whether the + launcher's maximum memory setting is too low (stutter from constant cleanup), too high for your computer + (the system and, on Apple silicon, the graphics run short and start swapping) or fine, with the number to set. + A one-time chat message only when something is wrong. +- Keep Compiled Shaders (Frame page, on): vanilla compiles every render pipeline without a Vulkan pipeline cache, + so the driver redoes all of it each launch. Vesuvio keeps one, saves it to `vesuvio-cache/` and reuses it: + faster start and no first-use shader hitches after the first launch, mostly on Windows. +- Merge Upload Barriers (Frame page, on): the Vulkan backend put a full GPU barrier after every copy and buffer + write. Back-to-back copies that touch different memory now share one, recorded before the next render pass, + clear, read-back, overlapping copy or submit. Chunk-loading frames go from hundreds of barriers (and, on Mac, + Metal blit encoders) to a handful. Counter "upload barriers merged". +- Direct Chunk Staging (World page, on): finished chunk meshes are written straight into mapped GPU-visible memory + and sent with one GPU copy, instead of a second CPU copy into transient memory first. Works with the chunk upload + budget. Counters "chunk KB staged direct" and "chunk MB staged direct". +- Skip Repeated Binds (Rendering page, on): in each Vulkan render pass the pipeline already bound is not bound + again, and no new descriptor set is made when the bound one already holds the same buffers and textures. Fewer + CPU-side commands for every entity, particle and GUI draw. F3 counters `binds skipped`, `desc sets skipped`, and + `draws` (draws per frame, each indirect sub-draw counted: Metal draws on the Mac). +- Face Culling: Smart Splits (Rendering page, on, macOS only): no face culling split costs an extra Metal draw while + the CPU is the limit; `faceCullingMinGap` applies as set while the graphics card is the limit, and always on + Windows and Linux. +- Reuse Terrain Draw Lists (Rendering page, on): the main view's terrain draw lists are rebuilt only when the + visible sections, their meshes or GPU placement, a fade-in or the camera's face culling cell changed. F3 counter + `draw lists reused %`. +- Submit timers: F3 (`submit encode us`, `submit wait us`, `gpu bound`) and the benchmark table (`encode`, `wait` + columns) show how much of each frame goes into `vkQueueSubmit` (MoltenVK's Metal encoding on the Mac) and into + waiting for the graphics card. +- Fog Culling (Rendering page, on): terrain sections, mobs, block entities and particles beyond the point where + the fog paints them plain fog colour are skipped (under water, lava, powder snow, Blindness/Darkness, the Nether, + boss fog), using the game's own fog distances of each frame. Main view only; off with Improved Transparency, + near the world border and while Etna Shaders draws. +- Sign Text, Item Frame and Beacon Beam Culling (Entities page, on): back-side and sub-pixel sign text, item frames + behind the solid wall they hang on, sub-pixel framed items and maps, and beacon beams wholly out of view are + skipped. Ideas from More Culling (own code). +- Frame page: Cap FPS When Paused (on, 60 fps in single player behind a menu) and Treat Hidden Window as Minimized + (on: a hidden or fully covered window draws like a minimized one, where the system reports it). + +## 0.1.2 +- Benchmark: a run stopped early (a key, a click, a move) now keeps the steps it already measured, in latest.log + and on the results screen, instead of throwing them away. +- Benchmark: the action bar shows the step and the time left while it runs. +- Benchmark: the player still drifting just after the menu closes no longer stops the run at once. + +## 0.1.1 +- Fixed a crash when entering a world with saved chunks ("Exception loading chunk", from the Light Chunk Locks + memory setting). +- Listing a hook in `disabledMixins` now also skips the hooks that depend on it, instead of crashing later. + +## 0.1.0 +- First version: leaf culling, face culling, entity/block entity/particle culling, animated textures only when + visible, idle frame cap, MoltenVK settings on macOS, memory savings, smoother chunk loading, faster world ticks, + settings screen with F8 and a built-in benchmark. diff --git a/CREDITS.md b/CREDITS.md index 6335daa..70bec36 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -11,3 +11,49 @@ listed below. ## Techniques Vesuvio's own code follows Ideas only, no code copied: +- Leaf face culling (`it.cosimo.vesuvio.leaves`): dropping leaf faces buried behind other leaves follows the idea + of OptiFine's "Smart Leaves" and of CullLeaves (MIT) / MoreCulling's leaf culling modes; + the depth-3 "smart" default, the look-ahead dirtying and all code are Vesuvio's own. +- Settings screen laid out like Sodium's (pages on the left, options list, description box): layout idea only + (`ui/VesuvioSettingsScreen.java`). +- Adding a key mapping by appending it to `Options.keyMappings` before `Options.load`: the approach Fabric API's + key binding registry uses (Apache 2.0), idea only, own code (`ui/mixin/OptionsKeyMixin.java`). +- Vesuvio memory (`src/main/java/it/cosimo/vesuvio/memory/`), own code following: + - FerriteCore by malte0811 (MIT, https://github.com/malte0811/FerriteCore): smaller PalettedContainer threading + detector, block shape and block state cache deduplication, resource location deduplication. + - ModernFix by embeddedt (LGPL, https://github.com/embeddedt/ModernFix): computing wall shapes once and sharing + them between wall blocks (here also fences, panes and bars). + +- Per-direction face culling of terrain sections (`it.cosimo.vesuvio.faces`): sorting each section's quads by + the way they face when it is built, and drawing only the facings that can face the camera. Idea from Sodium + (PolyForm Shield); no Sodium code was read or copied. +- Entity / block entity occlusion culling (`entities/OcclusionTest`, `entities/CullWorker`): visibility by casting + rays from the camera through the block grid to sample points on the faces of the box that face the camera, on a + background thread, results applied a frame later; idea from LogisticsCraft's OcclusionCulling + (https://github.com/LogisticsCraft/OcclusionCulling, MIT, Copyright (c) tr7zw / LogisticsCraft) as used by + EntityCulling. Independent implementation, no code copied from either. +- Animate only the animated sprites that are visible (`frame/AnimatedTextures.java`): idea from Sodium + (CaffeineMC, PolyForm Shield 1.0.0), own code and own visibility tracking. +- Lower frame rate while unfocused or paused behind a menu, skip drawing while minimized or hidden + (`frame/IdleFps.java`): idea from Dynamic FPS (juliand665 and LostLuma, MIT), own code, nothing ported. +- Sign text, item frame and beacon beam culling (`culling/DetailCulling.java`): ideas from More Culling by + FX - PR0CESS (GPL-3.0, ideas only, no code read or copied); the sub-pixel and wall-projection tests are + Vesuvio's own. +- Fog culling (`culling/FogCulling.java`): skipping what the fog fully covers, an old engine technique (and an idea + several Minecraft mods use); own code built on the game's own fog values. +- MoltenVK settings through VK_EXT_layer_settings at instance creation (`frame/MoltenVkSettings.java`): the + documented MoltenVK configuration parameters (Khronos MoltenVK, Apache 2.0), own code. +- Entity section lookup by direct key instead of the sorted-set walk (`ticks/SectionLookup`): idea from Lithium + (CaffeineMC, LGPL-3.0) and Paper; own code, order-preserving. +- Per-section lists of the few entity classes that can be collided with / are containers, for entity collisions + and hopper container lookups (`ticks/RareEntities`): idea from Lithium's entity class groups (LGPL-3.0); own code. +- Remembering each block state's pathfinding type (`ticks/PathTypeMemo`): idea from Lithium's ai.pathing + (LGPL-3.0); own code (memo of the vanilla method, invalidated on tag rebinds). +- POI sphere searches limited to the sub-chunks the sphere reaches (`ticks/mixin/PoiManagerMixin`): idea from + Lithium's ai.poi (LGPL-3.0); own code. +- Suffocation check without streams (`ticks/mixin/EntitySuffocationMixin`): idea from Lithium's + entity.fast_suffocation_check (LGPL-3.0); own code. +- Chunk update scheduling (`it.cosimo.vesuvio.chunks`): handing the build threads a bounded, nearest-first batch + each frame, a per-frame time budget for mesh uploads and distance-based translucent re-sort triggers follow ideas + from Sodium's chunk build system (PolyForm Shield: ideas only) and C2ME; skipping all-air sections, trimming the + block entities of section copies and the newest-mesh-wins check are Vesuvio's own. All code is Vesuvio's own. diff --git a/README.md b/README.md index aa3729b..39efa53 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ and uses less memory. It runs on its own or next to Etna, and Etna's shadows get Made first for Apple Silicon Macs (MoltenVK), and meant to work on Windows and Linux as well. -> **Status: in development.** Version 0.1.0 is being built. Each part below ships only once it has been +> **Status: in development.** Version 0.3.1 is in testing. Each part below ships only once it has been > checked in game. ## Features @@ -28,6 +28,13 @@ Made first for Apple Silicon Macs (MoltenVK), and meant to work on Windows and L you before doing any work on them. * Hides leaf faces buried inside a canopy, keeping the outer layers you actually see. * Skips mobs, chests, signs and particles hidden behind walls or outside your view. +* Skips terrain, mobs and particles lost in thick fog (under water, in lava, in the Nether, with Blindness), + sign text seen from behind, item frames behind their wall and beacon beams out of view. +* Talks to the Vulkan renderer directly: clears are folded into the passes that follow them, back-to-back + uploads share one GPU barrier, repeated binds are skipped, framebuffers are kept instead of rebuilt every + pass, and terrain draw lists are reused while nothing changed. +* On Apple silicon (shared memory): mob, particle and GUI vertices are written straight where the GPU reads + them, with no extra copy. **Every frame** * Updates animated textures (water, lava, fire, portals) only when they are on screen. @@ -35,11 +42,18 @@ Made first for Apple Silicon Macs (MoltenVK), and meant to work on Windows and L come back. * Smoother chunk loading when you move fast. * On Macs: lets MoltenVK compile shaders in parallel, so new effects don't stall the game. +* Keeps compiled shaders on disk, so the next launch skips the driver's work and first-use hitches. +* Caps the frame rate while paused behind a menu or while the window is covered. **Memory and world** * Smaller block-state tables: less memory, fewer pauses for garbage collection. +* Memory check: measures what the game really uses and tells you if the launcher's maximum memory is too low or + too high for your computer, with the number to set. * Faster world ticking in single player, with the game's rules exactly as in vanilla: same redstone, same mobs, same farms. +* Smoother chunk loading: chunks are built nearest first, a few at a time, and sent to the graphics card + within a time budget, so joining a world or flying over new terrain no longer brings long frames; empty sky + sections are not built at all. ## Install diff --git a/docs/benchmark.md b/docs/benchmark.md index 8ac0aef..69be42c 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -1,9 +1,47 @@ # Benchmarking Vesuvio -Vesuvio has its own benchmark (Options > Video Settings > Vesuvio > General > Benchmark). It is being built -for 0.1.0; this page describes it once it ships. +Vesuvio has its own benchmark: Options > Video Settings > Vesuvio > General > **Run benchmark**. -Tips for a fair run, whatever tool you use: -* Stand still in the same spot, looking the same way, with all chunks loaded. +## Running it +1. Join a world, stand somewhere typical with all chunks loaded, and look the way you usually play. +2. Press **Run benchmark**. The menu closes and the run starts. +3. Don't touch anything until the results screen opens. The action bar shows the step and the time left (about + 5 minutes with the default 10 s steps). + +Any key, click, move or turn, a menu opening, minimizing the window or leaving the world stops the run and puts +your settings back. Steps already measured are kept: they go to `logs/latest.log` and, after a key, click or +move, show on the results screen. + +## What it measures +Each step settles for at least 3 seconds (longer while chunks are being rebuilt), then measures for +`benchmarkSeconds` (10 by default). The steps are: Vesuvio off, Vesuvio on, then every live setting outside the +General page changed on its own (a switch flipped, a number set to its lowest value), then on and off again +(plus the GPU pass timers step, see below). How +far the two "on" (or "off") steps land apart is the noise; differences smaller than that mean nothing. + +The results table in `latest.log` has, per step: fps, mean frame time, 95th percentile frame time, the difference +from "on" in ms (a positive number means what that step switched off was saving time), the game's own CPU +frame time, and the end of the frame split in two: **encode**, ms per frame inside `vkQueueSubmit` (on macOS with +MoltenVK this is where the frame is turned into Metal commands, and where it waits for the screen with VSync on; +with Asynchronous Submit on it moves to another thread and this column only shows the hand-over), and **wait**, ms +per frame waiting for the graphics card to finish the frame before last (large when the graphics card is the +limit, near zero when the CPU is). The same two numbers, averaged over the last second, are on F3 as +`submit encode us` and `submit wait us`. The settings are never saved during a run. + +### GPU passes +Right after "Vesuvio on" the run has one more step, **GPU pass timers**: your settings plus GPU Pass Timers +(`gpuPassTimers`, Frame page), which is off in every other step. Its "vs on" is what measuring costs. Below the +main table, `latest.log` then gets a **GPU passes** table for that step: the graphics card's time per frame for the +whole frame and for each render pass by its label (the 15 largest, vanilla's and shader packs' such as Etna's +alike), the rest as "other passes", **uploads** (the staging and new-object command buffers) and **between passes** +(copies, clears and barriers outside passes), each in ms per frame and as a share of the frame. The times come from +Vulkan timestamps written just before and after each pass. The copy to the window at the very end is not included. +On a Mac, MoltenVK takes these samples between Metal command groups, so a pass's time can include a sliver of +whatever comes right after it. The step is skipped if the graphics driver cannot write timestamps. Your own +`gpuPassTimers` value is put back at the end. + +## Tips for a fair run * Use the same window mode each time (exclusive fullscreen is faster on macOS than a normal fullscreen window). * Turn VSync off and set the frame limit to unlimited, or the numbers stop at your screen's refresh rate. +* Compare runs only with the same spot, render distance, shaders and window size; the first log line of each run + records them. diff --git a/docs/settings.md b/docs/settings.md index 1c3e63c..31f2094 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -1,17 +1,97 @@ # Vesuvio settings Every setting lives in Options > Video Settings > **Vesuvio** and in `config/vesuvio.properties`. -This page fills in as each optimization lands. +A switch that is off makes that part of the game behave exactly like vanilla. Settings marked "restart" take +effect the next time the game starts; all others apply live. ## General | Setting | Default | What it does | |---|---|---| | Vesuvio (`enabled`) | On | Master switch, also on **F8**. Off makes every part behave like vanilla, live. | +| Log counters (`statsLog`) | Off | Writes fps and Vesuvio's counters (what each part skipped) to `logs/latest.log` every 5 seconds. | +| Benchmark step (`benchmarkSeconds`) | 10 | Seconds the built-in benchmark measures each step, after it settles. See [benchmark.md](benchmark.md). | | `disabledMixins` (file only) | empty | Comma-separated names of Vesuvio's hooks to skip at start-up, for troubleshooting. | ## Rendering | Setting | Default | What it does | |---|---|---| -| Leaf culling (`leafCulling`) | Smart | Hides leaf faces buried inside a canopy (only with cutout leaves on). Off: vanilla. Smart: a face goes only when the next three blocks that way are leaves or solid, so trees look the same from outside. Aggressive: two blocks, a bit faster, canopies a little more see-through. All: every face between two leaves, trees become hollow shells. Applies live; the world rebuilds once. | +| Leaf Culling (`leafCulling`) | Smart | Hides leaf faces buried inside a canopy (only with cutout leaves on). Off: vanilla. Smart: a face goes only when the next three blocks that way are leaves or solid, so trees look the same from outside. Aggressive: two blocks, a bit faster, canopies a little more see-through. All: every face between two leaves (trees become hollow shells). The world rebuilds once when changed. | +| Face Culling (`faceCulling`) | On | Skips terrain faces that point away from you before the graphics card works on them. | +| Face Culling: Split From (`faceCullingMinGap`) | 64 | How many hidden faces are worth one extra draw call when the graphics card's time is what counts. Lower skips more faces but costs more CPU. With Face Culling: Smart Splits on, the Mac uses it only while the graphics card is the limit. | +| Face Culling: Smart Splits (`faceCullingAutoSplit`) | On | macOS only: while the CPU is the limit (the game spends less than about 10% of the frame waiting for the graphics card, measured every second), no section draw is split, because on the Mac every extra draw costs CPU time (about 1 µs in MoltenVK) and the vertex work it would save lands on a graphics card that is waiting anyway. Faces at either end of a section are still skipped. While the graphics card is the limit, `faceCullingMinGap` applies as set. Off: `faceCullingMinGap` always applies as set. Does nothing on Windows and Linux. Same picture either way. | +| Reuse Terrain Draw Lists (`sectionDrawCache`) | On | The list of terrain draws for the main view is built again only when something it depends on changed (visible sections, their meshes, where they sit in GPU memory, fade-in, the camera crossing a face culling boundary). Otherwise last frame's list is used. Less CPU time and garbage per frame, same picture. Etna's shadow pass is never served from it. | +| Skip Repeated Binds (`skipRedundantBinds`) | On | In each Vulkan render pass, the pipeline already bound is not bound again, and a new descriptor set (the textures and buffers a draw uses) is not made when the one already bound holds exactly the same ones. Saves CPU time per draw for entities, particles and the GUI (MoltenVK encodes each bind into Metal). Same picture. | +| Fewer Water/Glass Re-sorts (`translucentSortThrottle`) | On | Far see-through sections are re-sorted only after you have moved far enough for it to matter. | +| Clear Fusion (`clearFusion`) | On | A clear followed by drawing into the same picture becomes part of that drawing (one full write and read less), clears nothing can see are skipped, and post effects get no depth buffers they never read. Same picture. Counters: clears fused, dropped, split. | +| Fog Culling (`fogCulling`) | On | Skips terrain, mobs, block entities and particles so deep in the fog that they would be plain fog colour anyway (under water, lava, powder snow, Blindness/Darkness, the Nether, boss fog). Uses the game's own fog distance each frame; in clear air nothing is skipped. Off by itself with Improved Transparency, near the world border, with debug overlays and while Etna Shaders draws. | + +## Entities + +| Setting | Default | What it does | +|---|---|---| +| Entity Culling (`entityCulling`) | On | Skips drawing mobs, items and other entities hidden behind solid blocks (checked on a background thread). | +| Block Entity Culling (`blockEntityCulling`) | On | Skips drawing chests, signs, banners and other block entities outside your view or behind solid blocks. | +| Particle Culling (`particleCulling`) | On | Skips particles in places the game already knows you cannot see. | +| Sign Text Culling (`signTextCulling`) | On | Skips sign text on the side facing away from you, and all of it once each letter is under one pixel. | +| Item Frame Culling (`itemFrameCulling`) | On | Skips item frames when you are behind the solid wall they hang on, and the framed item or map once it is under one pixel. | +| Beacon Beam Culling (`beaconBeamCulling`) | On | Skips beacon beams wholly outside your view (vanilla draws every beam every frame). | +| Faster Entity Models (`fastEntityModels`) | On | Each corner of a model box (mobs, players, chests...) is moved into place once instead of once per face that uses it. Exactly the same vertices, bit for bit. | +| Cache Sign Text (`signTextCache`) | On | Each sign line's glyph layout is kept instead of being redone every frame; only its vertices are written each frame. Rebuilt after a resource pack or font change; obfuscated text is never kept. | + +## Memory (all need a restart) + +The first row is the **memory check**, not a setting: after two minutes in a world it says whether the +launcher's maximum memory (Prism: Edit instance > Settings > Java > Maximum memory allocation) is too low, too +high for the computer, or fine, and which number to use. Too low makes the game clean up memory over and over +(stutter); too high leaves the rest of the computer short, and on Apple silicon the graphics share that memory, +so the system starts swapping (stutter too). In between, more memory does not make the game faster. The advice +is twice the heap the game keeps after cleanups (highest 5-minute floor) plus 1 GB, at least 3 GB, and at most +half the computer's memory on Apple silicon or all but 4 GB elsewhere. The same line is on F3. + +Turned off automatically when FerriteCore or ModernFix is installed, since they do the same. + +| Setting | Default | What it does | +|---|---|---| +| Light Chunk Locks (`memoryLightThreadChecks`) | On | Chunk data uses a tiny check instead of a lock object in every section. About 10-50 MB less. | +| Share Block Shapes (`memoryShareBlockShapes`) | On | Blocks with identical shapes share one shape object. | +| Share Block State Tables (`memoryShareStateTables`) | On | Block states with identical face tables share one table. About 1.5 MB less. | +| Share Id Names (`memoryShareIdNamespaces`) | On | Ids share one "minecraft" text instead of a copy each. About 1 MB less. | + +## Frame + +| Setting | Default | What it does | +|---|---|---| +| Animate Only Visible Textures (`animatedTextures`) | On | Water, lava, fire, portal and other animated block textures are redrawn only while something on screen uses them. | +| Reduce FPS When Idle (`idleFps`) | On | Caps the frame rate while the window is not focused, and draws almost nothing while it is minimized. | +| Idle FPS Limit (`idleFpsLimit`) | 10 | Frame rate while the window is open but not focused (3 to 60). | +| Treat Hidden Window as Minimized (`hiddenWindowFps`) | On | A hidden or fully covered window (macOS: Cmd+H, another Space, covered by other windows) draws almost nothing, like a minimized one, once the system has reported it for half a second of normal frames (so a brief false report after a long load does not drop the frame rate). Only where the system reports it. | +| Cap FPS When Paused (`pausedFps`) | On | Single player paused behind a menu: frame rate capped at the limit below. | +| Paused FPS Limit (`pausedFpsLimit`) | 60 | Frame rate while paused behind a menu (10 to 240). | +| Faster Shader Compiling (`fastStartupCompile`) | On, restart | macOS only: lets Metal compile shaders on more threads, so first-time stutters are shorter. | +| Keep Compiled Shaders (`pipelineCache`) | On, restart | Saves the driver's compiled shaders (Vulkan pipeline cache) to `vesuvio-cache/` when you quit and reuses them next launch: faster start, no hitch the first time a new mob, effect or shader pass shows. Checked against the GPU and driver. Biggest gain on Windows. | +| Asynchronous Submit (`asyncSubmit`) | Off, restart | macOS only, experimental: MoltenVK converts each frame to Metal on its own thread. Can help when the CPU is the limit. | +| No Metal Argument Buffers (`metalArgumentBuffersOff`) | Off, restart | macOS only, a benchmark experiment: sets MoltenVK's `MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS=0`, so each draw's descriptors are bound one by one instead of through a Metal argument buffer. Faster or slower is unknown: benchmark it both ways. Shader packs with more than 16 textures in one shader may fail with it on. | +| Merge Upload Barriers (`mergeTransferBarriers`) | On | Back-to-back copies to the graphics card that touch different memory share one full barrier instead of one each; the barrier is still recorded before anything that could use their data. Fewer GPU stalls and, on Mac, fewer Metal command groups when chunks load. | +| Keep Framebuffers (`framebufferCache`) | On | The Vulkan framebuffer of each render pass (which pictures it draws into) is kept and reused while the same pictures are drawn into again, instead of made and thrown away every pass. Thrown away when one of its pictures is. Same picture. Counters: framebuffers reused, framebuffers kept. | +| Direct Vertex Writes (`mappedVertexPools`) | On | Unified memory only (Apple silicon, integrated GPUs; read from the driver's memory heaps): entity, particle and GUI vertices are written straight into the buffers the GPU draws from, instead of a staging buffer plus a GPU copy and barrier each. A buffer is rewritten only after the GPU finished the frames that used it (the game's own buffer fences). Same picture. Counter: vertex copies skipped. | +| GPU Pass Timers (`gpuPassTimers`) | Off | A diagnostic: Vulkan timestamps just before and just after every render pass (never inside one) and around the frame's command buffers give the graphics card's time per pass, grouped by the pass's label (vanilla's and shader packs' passes alike). F3 shows the whole frame and the five slowest passes, averaged over a second; the benchmark switches it on for a step of its own and logs a "GPU passes" table. Results are read two frames later, after the game's own wait for that frame, so nothing waits for them. Same picture. Costs a little GPU time while on (more on Mac, where each sample becomes a tiny extra Metal blit); off writes nothing and keeps no query pool. Shows "not supported" if the graphics queue cannot write timestamps. | + +## World + +| Setting | Default | What it does | +|---|---|---| +| Smooth Chunk Building (`chunkScheduler`) | On | Builds chunks nearest first, a few at a time, instead of copying every changed section in one frame. Blocks you change are still rebuilt at once. | +| Skip Empty Sections (`skipEmptySections`) | On | Pieces of the world that hold only air (most of the sky) are not built at all. | +| Lighter Section Copies (`trimSectionCopies`) | On | Each section copy keeps only its own chests, signs and other block entities. | +| Chunk Upload Budget (`chunkUploadBudget`) | 2 ms | Most time per frame spent sending finished chunks to the graphics card. 0 is vanilla (everything at once). | +| Direct Chunk Staging (`mappedChunkStaging`) | On | Finished chunks are written straight into memory the graphics card reads, so each upload is one GPU copy without a second CPU copy first. Up to about 150 MB more memory. Takes effect the next time no chunk is waiting. | +| Compact Terrain Vertices (`compactTerrainVertices`) | Off, restart | Chunk meshes in 20 bytes per vertex instead of 28 (half-float positions, 16-bit texture coordinates; 24 bytes with float texture coordinates after a block atlas over 8192 was seen): about 29% less terrain graphics memory, upload bandwidth and vertex reading, most with Etna's shadow pass. Blocks on the 1/16 grid draw exactly as in vanilla; water, offset plants and rotated shapes move by up to 1/128 block and textures by a fraction of a texel, so it is off by default. Works with Etna and Fabric API (Indigo). | +| Fast Entity Lookups (`worldEntitySectionLookup`) | On | Entity searches look only at the areas inside the search box. | +| Skip Entities Nothing Can Bump (`worldRareEntityIndex`) | On, restart | Collision checks and hoppers look only at the few entity kinds that can matter to them. | +| Remember Pathfinding Block Types (`worldPathTypeCache`) | On | Mobs' path finding remembers each block's type instead of working it out again. | +| Narrow Villager and Bee Searches (`worldPoiVerticalRange`) | On | Searches for beds, workstations and hives skip heights outside their sphere. | +| Faster Suffocation Check (`worldFastSuffocationCheck`) | On | The per-tick "head inside a block" check without extra objects. | + +The world settings keep the game's rules exactly as in vanilla: same entities in the same order, same results. diff --git a/gradle.properties b/gradle.properties index 9fc9952..482546d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,5 +10,5 @@ loom_version=1.18-SNAPSHOT # Mod Properties # Bump on every build handed out: patch for fixes, minor for new optimizations. The jar and the version in # the game's mod list both become +mc. -mod_version=0.1.0 +mod_version=0.3.1 group=it.cosimo.vesuvio diff --git a/src/main/java/it/cosimo/vesuvio/Vesuvio.java b/src/main/java/it/cosimo/vesuvio/Vesuvio.java index 905fb3b..935e9a8 100644 --- a/src/main/java/it/cosimo/vesuvio/Vesuvio.java +++ b/src/main/java/it/cosimo/vesuvio/Vesuvio.java @@ -3,6 +3,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import it.cosimo.vesuvio.ticks.WorldTicks; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.loader.api.FabricLoader; @@ -14,10 +15,33 @@ public final class Vesuvio implements ClientModInitializer { public void onInitializeClient() { // Touch each optimization so its settings exist before the settings screen or the first frame. // (one line per optimization package) + it.cosimo.vesuvio.leaves.LeafCulling.init(); + it.cosimo.vesuvio.ui.VesuvioUi.init(); + it.cosimo.vesuvio.memory.MemoryBoost.init(); + it.cosimo.vesuvio.faces.FaceCulling.init(); + it.cosimo.vesuvio.draws.Draws.init(); + it.cosimo.vesuvio.entities.Culling.init(); + it.cosimo.vesuvio.culling.CullingBoost.init(); + it.cosimo.vesuvio.models.Models.init(); + it.cosimo.vesuvio.frame.FrameBoost.init(); + WorldTicks.init(); + it.cosimo.vesuvio.chunks.ChunkUpdates.init(); + it.cosimo.vesuvio.vertex.CompactVertices.init(); + it.cosimo.vesuvio.clears.ClearFusion.init(); VesuvioConfig.save(); LOGGER.info("Vesuvio {} ready, {} settings, optimizations {}", FabricLoader.getInstance().getModContainer("vesuvio").map(c -> c.getMetadata().getVersion() .getFriendlyString()).orElse("?"), VesuvioConfig.all().size(), VesuvioConfig.ENABLED.on() ? "on" : "off (master switch)"); } + + /** + * The client is changing worlds or leaving one (LevelChangeMixin). Each part drops what it still holds of the old + * world; all of it is rebuilt from the new world as needed. + */ + public static void levelChanged() { + it.cosimo.vesuvio.faces.SectionDrawCache.release(); + it.cosimo.vesuvio.clears.ClearFusion.release(); + it.cosimo.vesuvio.models.SignTextCache.invalidate(); + } } diff --git a/src/main/java/it/cosimo/vesuvio/VesuvioConfig.java b/src/main/java/it/cosimo/vesuvio/VesuvioConfig.java index 6ecb16a..0a6bc01 100644 --- a/src/main/java/it/cosimo/vesuvio/VesuvioConfig.java +++ b/src/main/java/it/cosimo/vesuvio/VesuvioConfig.java @@ -178,6 +178,13 @@ public static synchronized String raw(String key, String fallback) { return LOADED.getProperty(key, fallback); } + /** Stores a raw value that is not a setting (something remembered for the next start) and saves the file. */ + public static synchronized void setRaw(String key, String value) { + load(); + LOADED.setProperty(key, value); + save(); + } + private static void load() { if (loaded) { return; diff --git a/src/main/java/it/cosimo/vesuvio/VesuvioMixinPlugin.java b/src/main/java/it/cosimo/vesuvio/VesuvioMixinPlugin.java index 3f6ee08..addfce0 100644 --- a/src/main/java/it/cosimo/vesuvio/VesuvioMixinPlugin.java +++ b/src/main/java/it/cosimo/vesuvio/VesuvioMixinPlugin.java @@ -3,6 +3,7 @@ import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import org.objectweb.asm.tree.ClassNode; @@ -12,8 +13,71 @@ /** * Lets a broken optimization be switched off without a new build: any mixin whose simple class name is in * "disabledMixins" in config/vesuvio.properties is not applied. Live on/off switches live in VesuvioConfig. + * A mixin that casts to an interface (or calls an accessor) another mixin adds goes with it: it would otherwise throw + * ClassCastException the first time it runs. */ public final class VesuvioMixinPlugin implements IMixinConfigPlugin { + /** Mixin -> the mixins whose interfaces it (or the code it calls) casts to. */ + private static final Map> NEEDS = Map.ofEntries( + // chunks + Map.entry("ResortThrottleMixin", List.of("RenderSectionStateMixin")), + Map.entry("ExtractSchedulingMixin", List.of("RenderSectionInvoker")), + Map.entry("UberBufferBudgetMixin", List.of("StagedEntryAccessor", "StagingBufferAccessor")), + Map.entry("StagingMappedMixin", List.of("StagingCpuMixin")), + // clears: holding a clear back is only safe with every hook that sends, splits or drops it + Map.entry("ClearsFrontendEncoderMixin", List.of("ClearsFrontendRenderPassMixin", "ClearsFrontendSurfaceMixin", + "ClearsVulkanEncoderAccessor", "ClearsVulkanEncoderMixin", "ClearsVulkanTextureMixin")), + Map.entry("ClearsFrontendRenderPassMixin", List.of("ClearsVulkanEncoderAccessor")), + Map.entry("ClearsPostChainMixin", List.of("ClearsPostPassAccessor")), + // entities + Map.entry("LevelExtractorMixin", List.of("EntityCullStateMixin", "BlockEntityCullStateMixin", + "EntityRendererInvoker", "FrustumInvoker")), + // reads the particle's position through the culling package's accessor + Map.entry("QuadParticleGroupMixin", List.of("ParticleAccessor")), + // culling + Map.entry("CullLevelExtractorMixin", List.of("CullEntityRendererInvoker", "FogRendererAccessor")), + // entity fog culling runs on the state CullLevelExtractorMixin sets up each frame + Map.entry("CullEntityVisibilityMixin", List.of("CullLevelExtractorMixin", "CullEntityRendererInvoker")), + // section fog culling only acts inside the main-view mark CullLevelRendererMixin sets + Map.entry("CullSectionMeshMixin", List.of("CullLevelRendererMixin")), + Map.entry("CullLevelRendererMixin", List.of("FogRendererAccessor")), + Map.entry("FogParticleGroupMixin", List.of("ParticleAccessor")), + // models: the sign side casts SignText to the holder; without the font generation a kept layout could + // outlive a resource reload; without the sign side the text side never sees a cached line (harmless). + Map.entry("SignTextCacheMixin", List.of("SignTextLinesMixin", "FontSetGenerationMixin")), + Map.entry("TextFeatureCacheMixin", List.of("FontSetGenerationMixin")), + // faces + Map.entry("SectionCompilerMixin", List.of("SectionCompilerResultsMixin")), + Map.entry("CompiledSectionMeshMixin", List.of("SectionCompilerResultsMixin")), + // Without the allocation counter the cache could hand back stale lists; without the main-view mark it + // never serves anything. + Map.entry("LevelRendererDrawCacheMixin", List.of("UberAllocationsMixin", "LevelRendererFacesMixin")), + // frame + Map.entry("FrameSectionCompilerMixin", List.of("FrameSectionResultsMixin")), + Map.entry("FrameCompiledSectionMeshMixin", List.of("FrameSectionResultsMixin", "FrameSpriteContentsMixin")), + Map.entry("FrameSpriteContentsMixin", List.of("FrameAnimationStateMixin")), + Map.entry("FrameAnimationStateMixin", List.of("FrameSpriteContentsMixin")), + Map.entry("FrameAtlasSpriteMixin", List.of("FrameSpriteContentsMixin")), + Map.entry("FrameBlockModelFeatureRendererMixin", List.of("FrameSpriteContentsMixin")), + Map.entry("FrameItemFeatureRendererMixin", List.of("FrameSpriteContentsMixin")), + Map.entry("FrameMovingBlockFeatureRendererMixin", List.of("FrameSpriteContentsMixin")), + Map.entry("FrameLevelRendererMixin", List.of("FrameSpriteContentsMixin")), + // Kept framebuffers must go when their views close, or a reused view handle could match a stale one. + Map.entry("FrameFramebufferCacheMixin", List.of("FrameTextureViewCloseMixin")), + Map.entry("FrameTextureViewCloseMixin", List.of("FrameFramebufferCacheMixin")), + Map.entry("FrameStagedVertexBufferMixin", List.of("FrameStagedDrawAccessor", "FrameStagedVertexBufferInvoker")), + // The staging buffer's timestamps go through the encoder's recorder. + Map.entry("FrameGpuTimersStagingMixin", List.of("FrameGpuTimersEncoderMixin")), + // ticks + Map.entry("LevelMixin", List.of("EntitySectionMixin", "LevelEntityGetterAdapterAccessor")), + Map.entry("HopperBlockEntityMixin", List.of("LevelMixin", "EntitySectionMixin")), + Map.entry("WalkNodeEvaluatorMixin", List.of("BlockStatePathTypeMixin")), + Map.entry("PoiManagerMixin", List.of("SectionStorageAccess")), + // memory + Map.entry("BlockShapeTableMixin", List.of("VoxelShapeAccessor", "BitSetDiscreteVoxelShapeAccessor")), + Map.entry("StateCacheShapeMixin", List.of("VoxelShapeAccessor", "BitSetDiscreteVoxelShapeAccessor")), + Map.entry("BlockStateOcclusionShapeMixin", List.of("VoxelShapeAccessor", "BitSetDiscreteVoxelShapeAccessor"))); + private final Set disabled = new HashSet<>(); @Override @@ -23,6 +87,16 @@ public void onLoad(String mixinPackage) { disabled.add(name.trim()); } } + boolean changed = !disabled.isEmpty(); + while (changed) { + changed = false; + for (Map.Entry> entry : NEEDS.entrySet()) { + if (!disabled.contains(entry.getKey()) && entry.getValue().stream().anyMatch(disabled::contains)) { + disabled.add(entry.getKey()); + changed = true; + } + } + } } @Override @@ -34,7 +108,8 @@ public String getRefMapperConfig() { public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { String simple = mixinClassName.substring(mixinClassName.lastIndexOf('.') + 1); if (disabled.contains(simple)) { - Vesuvio.LOGGER.warn("Vesuvio: {} switched off in vesuvio.properties (disabledMixins)", simple); + Vesuvio.LOGGER.warn("Vesuvio: {} switched off in vesuvio.properties (disabledMixins, or a mixin it needs is)", + simple); return false; } return true; diff --git a/src/main/java/it/cosimo/vesuvio/chunks/ChunkScheduler.java b/src/main/java/it/cosimo/vesuvio/chunks/ChunkScheduler.java new file mode 100644 index 0000000..829479c --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/ChunkScheduler.java @@ -0,0 +1,243 @@ +package it.cosimo.vesuvio.chunks; + +import java.util.Arrays; + +import it.cosimo.vesuvio.chunks.mixin.RenderSectionInvoker; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import net.minecraft.client.SectionUpdateTracker; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.chunk.CompiledSectionMesh; +import net.minecraft.client.renderer.chunk.RenderRegionCache; +import net.minecraft.client.renderer.chunk.SectionMesh; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.client.renderer.state.level.LevelRenderState; +import net.minecraft.client.renderer.state.level.SectionUpdateRenderState; +import net.minecraft.core.SectionPos; +import net.minecraft.util.Mth; +import net.minecraft.util.Util; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.phys.Vec3; + +/** + * Replaces the "sectionUpdates" loop of {@code LevelExtractor.extract} (render thread, once a frame). + * + *

Vanilla walks every section in view and, for each dirty one that may be built, copies its 3x3x3 neighbourhood + * of block data on the render thread and queues a compile task, all in the same frame. After joining a world, after + * a teleport, or when flying into new terrain that is hundreds or thousands of sections in one frame (a long frame), + * and the task queue grows to thousands of entries that every worker scans linearly, under one lock, for each task + * it takes. Here the same sections are found with the same rules, but: + *

    + *
  • sections the player changed and sections within vanilla's "nearby" distance (about 27 blocks) are queued at + * once, as in vanilla;
  • + *
  • the others are queued only up to what keeps the workers busy (the queue is topped up to + * {@link #TARGET_QUEUE} tasks, at least {@link #MIN_PER_FRAME} a frame), nearest first; the rest simply stay dirty + * and are looked at again next frame, with the camera's new view, so turning or flying never leaves the workers + * building sections that went out of view while newer ones in front wait;
  • + *
  • with {@link ChunkUpdates#SKIP_EMPTY}, a section that holds only air gets the empty mesh straight away (what its + * compile would produce: no blocks, no block entities), with no copy and no task.
  • + *
+ * Nothing is dropped: a deferred section keeps its dirty flag, exactly like a dirty section out of view in vanilla, + * and is built as soon as it is among the nearest pending ones. + */ +public final class ChunkScheduler { + private static final ObjectArrayList NOTHING = new ObjectArrayList<>(0); + /** Squared block distance below which vanilla calls a section "nearby" (LevelRenderer.compileSections). */ + private static final long NEARBY_DIST_SQR = 768L; + private static final int THREADS = Util.maxAllowedExecutorThreads(); + /** Tasks the compile queue is topped up to each frame: enough for every worker for a frame or two. */ + static final int TARGET_QUEUE = Math.max(48, THREADS * 24); + /** New far tasks allowed every frame even with a full queue, so the nearest pending ones always get in. */ + static final int MIN_PER_FRAME = Math.max(4, THREADS); + + // Counters (render thread writes, read anywhere). + private static volatile int scheduledLastFrame; + private static volatile int deferredLastFrame; + private static volatile int emptyLastFrame; + private static volatile long emptyTotal; + + // Scratch, render thread only. + private static SectionRenderDispatcher.RenderSection[] candSection = new SectionRenderDispatcher.RenderSection[256]; + private static SectionUpdateTracker.SectionDirtyState[] candState = new SectionUpdateTracker.SectionDirtyState[256]; + private static int[] candBucket = new int[256]; + private static final int[] HISTOGRAM = new int[256]; + + private ChunkScheduler() { + } + + /** Counter: section rebuilds queued in the last frame (incl. nearby / player-changed ones). */ + public static int scheduledLastFrame() { + return scheduledLastFrame; + } + + /** Counter: dirty sections in view left for a later frame in the last frame (0 = nothing waiting). */ + public static int deferredLastFrame() { + return deferredLastFrame; + } + + /** Counter: all-air sections given their empty mesh directly in the last frame. */ + public static int emptyLastFrame() { + return emptyLastFrame; + } + + /** Counter: all-air sections given their empty mesh directly since start-up (copies + tasks saved). */ + public static long emptyTotal() { + return emptyTotal; + } + + /** + * Render thread, in place of vanilla's loop. Returns the list vanilla's loop should walk: {@code visible} when + * both settings are off (vanilla does everything), otherwise an empty list (the work was done here). + * {@code cache} is the region cache vanilla made for its loop: when the work is done here, vanilla's loop gets + * nothing to walk, so the regions are made in its cache as that loop would have made them. + */ + public static ObjectArrayList schedule( + ObjectArrayList visible, LevelRenderer renderer, ClientLevel level, + SectionUpdateTracker tracker, LevelRenderState state, Vec3 cameraPos, RenderRegionCache cache) { + boolean limit = ChunkUpdates.SCHEDULER.on(); + boolean skipEmpty = ChunkUpdates.SKIP_EMPTY.on() && !level.isDebug(); + SectionRenderDispatcher dispatcher = renderer.sectionRenderDispatcher(); + if ((!limit && !skipEmpty) || dispatcher == null) { + scheduledLastFrame = 0; + deferredLastFrame = 0; + emptyLastFrame = 0; + return visible; + } + int camX = Mth.floor(cameraPos.x); + int camY = Mth.floor(cameraPos.y); + int camZ = Mth.floor(cameraPos.z); + int scheduled = 0; + int empty = 0; + int candidates = 0; + Object[] elements = visible.elements(); + for (int i = 0, n = visible.size(); i < n; i++) { + SectionRenderDispatcher.RenderSection section = (SectionRenderDispatcher.RenderSection) elements[i]; + long node = section.getSectionNode(); + SectionUpdateTracker.SectionDirtyState dirty = tracker.getDirtyState(node); + // Vanilla's condition, unchanged: dirty, and either already built once or all 8 neighbour columns lit. + if (dirty == null || !dirty.isDirty() + || (section.sectionMesh.get() == CompiledSectionMesh.UNCOMPILED && !tracker.hasAllNeighbors(level, node))) { + continue; + } + if (skipEmpty && isOnlyAir(level, node)) { + setEmpty(section, dispatcher); + dirty.setNotDirty(); + empty++; + continue; + } + if (!limit || dirty.isDirtyFromPlayer()) { + submit(section, dirty, level, cache, state); + scheduled++; + continue; + } + long dx = SectionPos.sectionToBlockCoord(SectionPos.x(node)) + 8 - camX; + long dy = SectionPos.sectionToBlockCoord(SectionPos.y(node)) + 8 - camY; + long dz = SectionPos.sectionToBlockCoord(SectionPos.z(node)) + 8 - camZ; + long distSqr = dx * dx + dy * dy + dz * dz; + if (distSqr < NEARBY_DIST_SQR) { + submit(section, dirty, level, cache, state); + scheduled++; + continue; + } + if (candidates == candSection.length) { + candSection = Arrays.copyOf(candSection, candidates * 2); + candState = Arrays.copyOf(candState, candidates * 2); + candBucket = Arrays.copyOf(candBucket, candidates * 2); + } + candSection[candidates] = section; + candState[candidates] = dirty; + candBucket[candidates] = Math.min(255, (int) Math.sqrt((double) distSqr) >> 4); + candidates++; + } + + int deferred = 0; + if (candidates > 0) { + int allowed = Math.max(MIN_PER_FRAME, TARGET_QUEUE - dispatcher.getCompileQueueSize()); + if (candidates <= allowed) { + for (int i = 0; i < candidates; i++) { + submit(candSection[i], candState[i], level, cache, state); + } + scheduled += candidates; + } else { + // Nearest first: whole distance rings (one section wide) while they fit, then part of the next. + int[] hist = HISTOGRAM; + Arrays.fill(hist, 0); + for (int i = 0; i < candidates; i++) { + hist[candBucket[i]]++; + } + int cut = 0; + int taken = 0; + while (cut < hist.length && taken + hist[cut] <= allowed) { + taken += hist[cut]; + cut++; + } + int fromCut = allowed - taken; + for (int i = 0; i < candidates; i++) { + int bucket = candBucket[i]; + if (bucket < cut || (bucket == cut && fromCut-- > 0)) { + submit(candSection[i], candState[i], level, cache, state); + scheduled++; + } else { + deferred++; + } + } + } + Arrays.fill(candSection, 0, candidates, null); + Arrays.fill(candState, 0, candidates, null); + } + scheduledLastFrame = scheduled; + deferredLastFrame = deferred; + emptyLastFrame = empty; + emptyTotal += empty; + return NOTHING; + } + + /** Exactly what vanilla's loop does for a section it rebuilds. */ + private static void submit(SectionRenderDispatcher.RenderSection section, SectionUpdateTracker.SectionDirtyState dirty, + ClientLevel level, RenderRegionCache cache, LevelRenderState state) { + long node = section.getSectionNode(); + state.sectionUpdateRenderStates.add( + new SectionUpdateRenderState(node, dirty.isDirtyFromPlayer(), cache.createRegion(level, node))); + dirty.setNotDirty(); + } + + /** + * Whether the section holds no block at all, read from the same chunk data vanilla's section copy would read + * this frame. Missing chunks and out-of-range sections take vanilla's path. + */ + private static boolean isOnlyAir(ClientLevel level, long node) { + LevelChunk chunk = level.getChunkSource().getChunk(SectionPos.x(node), SectionPos.z(node), false); + if (chunk == null) { + return false; + } + int index = chunk.getSectionIndexFromSectionY(SectionPos.y(node)); + LevelChunkSection[] sections = chunk.getSections(); + return index >= 0 && index < sections.length && sections[index].hasOnlyAir(); + } + + /** + * What a compile of an all-air section ends in (SectionRenderDispatcher.RenderSection.CompileTask.doTask with no + * rendered layer and no block entity), done here on the render thread instead of a worker: cancel the section's + * pending tasks (as createCompileTask does, so an older build can't land later), show the EMPTY mesh, free the + * old one's GPU memory under the dispatcher's lock. + */ + private static void setEmpty(SectionRenderDispatcher.RenderSection section, SectionRenderDispatcher dispatcher) { + RenderSectionInvoker invoker = (RenderSectionInvoker) (Object) section; + invoker.vesuvio$cancelTasks(); + // Counts as a build started now: an older build of this section still on its way can't replace it later. + MeshOrder.taskStarted(Util.getNanos()); + SectionMesh old = invoker.vesuvio$setSectionMesh(CompiledSectionMesh.EMPTY); + dispatcher.lock(); + try { + invoker.vesuvio$releaseSectionMesh(old); + } finally { + dispatcher.unlock(); + } + } + + /** For LevelRenderer.hasRenderedAllSections: false while dirty sections in view still wait for their turn. */ + public static boolean nothingDeferred() { + return deferredLastFrame == 0; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/ChunkStaging.java b/src/main/java/it/cosimo/vesuvio/chunks/ChunkStaging.java new file mode 100644 index 0000000..2ac715f --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/ChunkStaging.java @@ -0,0 +1,185 @@ +package it.cosimo.vesuvio.chunks; + +import java.nio.ByteBuffer; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.renderpearl.api.buffers.GpuBuffer; +import com.mojang.renderpearl.api.buffers.GpuBufferSlice; +import com.mojang.renderpearl.api.commands.CommandEncoder; +import com.mojang.renderpearl.api.commands.GpuFence; + +/** + * Setting mappedChunkStaging: finished chunk meshes go straight into GPU-visible memory. + * + *

Vanilla's chunk staging area on Vulkan is plain CPU memory (StagingBuffer.Cpu, 98 MB): a compile worker copies + * each finished mesh into it, and at upload time writeToBuffer copies the bytes a second time, into the frame's + * transient staging memory, before the GPU copy into the chunk heap. The game also has a mapped variant + * (PersistentlyMapped: workers write into a host-visible buffer and the upload is one GPU copy from it), but it only + * picks it where writeToBuffer is slow, which is never on Vulkan. This uses the same idea inside the Cpu area, so it + * can be switched live: while the setting is on, the area's write buffer is one of three host-visible, host-coherent + * GPU buffers (half the area each, like the mapped variant), and the upload copies from it with copyToBuffer. One + * memcpy per mesh fewer, no transient staging for chunks; the GPU copy is the same. + * + *

Safety with frames in flight. The staging area is a bump allocator that starts over only after a frame that + * sent every staged mesh (vanilla's rotateBuffer point, after the budget of {@link ChunkUploads} let everything + * through); nothing is written twice into one buffer before that. At that point the buffer just used is retired + * with a fence on the frame being recorded (it signals once every copy recorded so far has finished on the GPU), + * and the next buffer is taken only if its own fence has already signalled, checked without waiting. If none of the + * three is free (three restarts within two frames), that round simply uses the malloc'd area with vanilla's + * writeToBuffer path. Host writes made before vkQueueSubmit to host-coherent memory are visible to the commands of + * that submit, and the workers write under the dispatcher's lock, which the render thread holds while it records the + * copies (and before it submits). Mode changes happen only at those restart points or while nothing is staged, so + * a staged mesh is always copied from the memory it was written to. + */ +public final class ChunkStaging { + private static long bytesTotal; + private static long bytesSinceFrame; + private static volatile int kbLastFrame; + private static volatile int fallbacks; + + private ChunkStaging() { + } + + public static void init() { + it.cosimo.vesuvio.ui.VesuvioStats.register("chunk KB staged direct", () -> kbLastFrame); + it.cosimo.vesuvio.ui.VesuvioStats.register("chunk MB staged direct", () -> (int) (bytesTotal >> 20)); + } + + /** Counter: chunk bytes sent from mapped staging since start-up. */ + public static long bytesTotal() { + return bytesTotal; + } + + /** Counter: times all three mapped buffers were still in use (that round used vanilla's path). */ + public static int fallbacks() { + return fallbacks; + } + + /** Render thread, after the end-of-frame chunk upload (ChunkUploads.endFrame): publishes the frame's count. */ + static void endFrame() { + kbLastFrame = (int) (bytesSinceFrame >> 10); + bytesSinceFrame = 0; + } + + /** + * The mapped stand-in for one chunk staging area. Every method runs on the render thread with the chunk + * dispatcher's lock held (StagingBuffer.create, startUploading and rotateBuffer are only reached that way), and + * the compile workers read {@link #data()} under the same lock, so no other synchronisation is needed. + */ + public static final class Area { + private static final int COUNT = 3; + + private final String label; + private final int size; + private final GpuBuffer[] buffers = new GpuBuffer[COUNT]; + private final GpuFence[] fences = new GpuFence[COUNT]; + private int current = -1; + private GpuBufferSlice.MappedView view; + private ByteBuffer data; + + public Area(String name, int areaSize) { + this.label = name + " staging (Vesuvio)"; + this.size = Math.max(1, areaSize / 2); + } + + /** The mapped write buffer, or null while the malloc'd area is in use. */ + public ByteBuffer data() { + return this.data; + } + + public boolean mapped() { + return this.view != null; + } + + /** Nothing is staged (write offset 0): follows the setting now. */ + public void sync() { + boolean want = ChunkUpdates.MAPPED_STAGING.on(); + if (want && this.view == null) { + acquire(); + } else if (!want) { + // Nothing was written into the current buffer since it was taken (the offset is still 0), so it can + // be dropped without a fence; closing queues the GPU buffers for destruction after the frames in + // flight, which may still be copying from the older ones. + unmap(); + freeAll(); + } + } + + /** + * Vanilla's rotateBuffer point: every staged buffer was copied (or dropped) and the area starts over. Retires + * the buffer in use with a fence on the frame being recorded, then takes a free one or goes back to the + * malloc'd area. + */ + public void restart() { + if (this.view != null) { + unmap(); + this.fences[this.current] = RenderSystem.getDevice().createCommandEncoder().createFence(); + } + if (ChunkUpdates.MAPPED_STAGING.on()) { + acquire(); + } else { + freeAll(); + } + } + + /** StagingBuffer.Cpu.copyTo while mapped: one GPU copy from the mapped buffer into the chunk heap. */ + public void copy(CommandEncoder encoder, GpuBuffer dst, long dstOffset, long stagingOffset, long copySize) { + encoder.copyToBuffer(this.buffers[this.current].slice(stagingOffset, copySize), dst.slice(dstOffset, copySize)); + bytesSinceFrame += copySize; + bytesTotal += copySize; + } + + /** The staging area is closed (the chunk renderer goes away). */ + public void close() { + unmap(); + freeAll(); + } + + /** Maps the next buffer whose last use has finished on the GPU, without waiting; none: stays unmapped. */ + private void acquire() { + for (int k = 1; k <= COUNT; k++) { + int i = Math.floorMod(this.current + k, COUNT); + GpuFence fence = this.fences[i]; + if (fence != null) { + if (!fence.awaitCompletion(0L)) { + continue; + } + fence.close(); + this.fences[i] = null; + } + if (this.buffers[i] == null) { + final int index = i; + this.buffers[i] = RenderSystem.getDevice().createBuffer(() -> this.label + " #" + index, + GpuBuffer.USAGE_MAP_WRITE | GpuBuffer.USAGE_COPY_SRC, this.size); + } + this.view = this.buffers[i].map(false, true); + this.data = this.view.data(); + this.current = i; + return; + } + fallbacks++; + } + + private void unmap() { + if (this.view != null) { + this.view.close(); + this.view = null; + this.data = null; + } + } + + private void freeAll() { + for (int i = 0; i < COUNT; i++) { + if (this.fences[i] != null) { + this.fences[i].close(); + this.fences[i] = null; + } + if (this.buffers[i] != null) { + this.buffers[i].close(); + this.buffers[i] = null; + } + } + this.current = -1; + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/ChunkStagingHost.java b/src/main/java/it/cosimo/vesuvio/chunks/ChunkStagingHost.java new file mode 100644 index 0000000..0d54db2 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/ChunkStagingHost.java @@ -0,0 +1,9 @@ +package it.cosimo.vesuvio.chunks; + +/** Added to vanilla's malloc-backed chunk staging area (StagingBuffer.Cpu) by StagingCpuMixin. */ +public interface ChunkStagingHost { + /** The mapped area that may stand in for the malloc'd one, or null for staging areas Vesuvio leaves alone. */ + ChunkStaging.Area vesuvio$stagingArea(); + + void vesuvio$setStagingArea(ChunkStaging.Area area); +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/ChunkUpdates.java b/src/main/java/it/cosimo/vesuvio/chunks/ChunkUpdates.java new file mode 100644 index 0000000..47d6fc5 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/ChunkUpdates.java @@ -0,0 +1,43 @@ +package it.cosimo.vesuvio.chunks; + +import it.cosimo.vesuvio.VesuvioConfig; + +/** + * Smoother, faster chunk updates on the client. Six independent parts, each with its own setting: + *

    + *
  • {@link ChunkScheduler} ({@link #SCHEDULER}): hands the compile workers only as many new sections per frame as + * they can take, nearest first, instead of copying every dirty section in view in one frame.
  • + *
  • {@link ChunkScheduler} ({@link #SKIP_EMPTY}): sections that hold only air get their (empty) result at once, + * without a world copy or a compile task.
  • + *
  • {@link SectionCopyTrim} ({@link #TRIM_COPIES}): the world copy of a section keeps only its own block + * entities, not the whole chunk's.
  • + *
  • {@link ChunkUploads} ({@link #UPLOAD_BUDGET}): the finished meshes are sent to the GPU within a time budget per + * frame; the rest waits for the next frame.
  • + *
  • {@link ChunkStaging} ({@link #MAPPED_STAGING}): the workers write finished meshes straight into mapped GPU + * memory, and the upload is one GPU copy from there instead of a second CPU copy first.
  • + *
  • {@link TranslucentSort} ({@link #SORT_THROTTLE}): far see-through sections are re-sorted when the camera moved + * enough for their order to change, not on every block step.
  • + *
+ */ +public final class ChunkUpdates { + /** Bounded, nearest-first scheduling of section rebuilds. Live. */ + public static final VesuvioConfig.Toggle SCHEDULER = VesuvioConfig.toggle("world", "chunkScheduler", true, false); + /** All-air sections become empty at once (no copy, no compile task). Live. */ + public static final VesuvioConfig.Toggle SKIP_EMPTY = VesuvioConfig.toggle("world", "skipEmptySections", true, false); + /** Section copies keep only their own block entities. Live. */ + public static final VesuvioConfig.Toggle TRIM_COPIES = VesuvioConfig.toggle("world", "trimSectionCopies", true, false); + /** Milliseconds per frame for sending finished chunk meshes to the GPU; 0 = no limit (vanilla). Live. */ + public static final VesuvioConfig.Number UPLOAD_BUDGET = VesuvioConfig.number("world", "chunkUploadBudget", 0, 16, 2, false); + /** Finished meshes are staged in mapped GPU memory: one memcpy fewer per mesh. Live (from the next empty area). */ + public static final VesuvioConfig.Toggle MAPPED_STAGING = VesuvioConfig.toggle("world", "mappedChunkStaging", true, false); + /** Re-sort far translucent sections only after the camera moved enough. Live. */ + public static final VesuvioConfig.Toggle SORT_THROTTLE = VesuvioConfig.toggle("rendering", "translucentSortThrottle", true, false); + + private ChunkUpdates() { + } + + /** Creates the settings (called from Vesuvio.onInitializeClient). */ + public static void init() { + ChunkStaging.init(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/ChunkUploads.java b/src/main/java/it/cosimo/vesuvio/chunks/ChunkUploads.java new file mode 100644 index 0000000..ed03a98 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/ChunkUploads.java @@ -0,0 +1,161 @@ +package it.cosimo.vesuvio.chunks; + +import com.mojang.blaze3d.vertex.StagingBuffer; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.chunks.mixin.StagedEntryAccessor; +import it.cosimo.vesuvio.chunks.mixin.StagingBufferAccessor; +import it.unimi.dsi.fastutil.objects.Object2ObjectMap; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.ObjectIterator; +import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; + +/** + * Time budget for sending finished chunk meshes to the GPU (render thread). + * + *

Compile workers copy each finished mesh into a 98 MB staging area; once a frame, at the end of + * {@code LevelRenderer.render}, {@code SectionRenderDispatcher.uploadTerrainBuffersToGpu} sends everything staged: + * for every vertex and index buffer of every finished section it allocates GPU space, copies the bytes again into + * the frame's transfer memory (the Vulkan backend's writeToBuffer, 512 KB blocks allocated as needed), records a + * copy command plus a full memory barrier, and swaps the section's mesh. After a join, a teleport or while flying, + * one frame can carry hundreds of sections and tens of MB this way, all while the workers wait on the lock. + * + *

With a budget, each {@code UberGpuBuffer.uploadStagedAllocations} call of that frame only takes the entries + * that fit in the time left (estimated from the measured cost per byte), at least one entry per frame; the others + * stay staged, which vanilla itself already allows (it stops early after growing a GPU heap), and go first in line + * next frame. A mesh is shown only once all its buffers are on the GPU (vanilla's rule), so a section never shows + * half-built: it simply shows its old mesh a frame longer. If entries keep waiting, the budget grows each frame + * (x2, x3, ...) and after {@link #FORCE_AFTER} frames everything is sent at once, as vanilla does, so nothing can + * wait forever and the staging area is always emptied again. + * + *

The staging area is a bump allocator that starts over only after a frame that sent every staged buffer; while + * anything is held it keeps filling, and once full the workers spin on the dispatcher's lock until it is emptied. + * So a frame that finds it more than {@link #FLUSH_FILL} full sends everything (vanilla behaviour). + */ +public final class ChunkUploads { + /** Frames in a row with entries left over after which one frame sends everything (vanilla behaviour). */ + static final int FORCE_AFTER = 10; + /** Per-entry overhead (allocation, copy command, barrier, callback), counted as this many bytes. */ + static final long ENTRY_COST_BYTES = 4096; + /** Share of the staging area in use above which the budget is ignored for the frame, so the area is reused. */ + static final double FLUSH_FILL = 0.5; + + /** True only during the end-of-frame upload called from LevelRenderer.render, while the budget applies. */ + private static boolean active; + private static long deadline; + private static boolean sentAny; + private static long start; + private static long units; + private static int held; + private static int backlogFrames; + /** Measured cost of one unit (byte), nanoseconds; starts at about 4 GB/s. */ + private static double nsPerUnit = 0.25; + + private static volatile long uploadNanosLastFrame; + private static volatile int heldLastFrame; + + private ChunkUploads() { + } + + /** Counter: time the last end-of-frame chunk upload took, in nanoseconds. */ + public static long uploadNanosLastFrame() { + return uploadNanosLastFrame; + } + + /** Counter: staged buffers left for the next frame by the budget in the last frame (0 = all sent). */ + public static int heldLastFrame() { + return heldLastFrame; + } + + /** Counter: frames in a row that left buffers for the next one. */ + public static int backlogFrames() { + return backlogFrames; + } + + /** Render thread, before the end-of-frame upload. */ + public static void beginFrame() { + int ms = VesuvioConfig.ENABLED.on() ? ChunkUpdates.UPLOAD_BUDGET.get() : 0; + start = System.nanoTime(); + units = 0; + held = 0; + sentAny = false; + if (ms <= 0) { + backlogFrames = 0; + active = false; + return; + } + active = backlogFrames < FORCE_AFTER; + deadline = start + ms * 1_000_000L * (1 + backlogFrames); + } + + /** Render thread, after the end-of-frame upload (also when it threw). */ + public static void endFrame() { + long took = System.nanoTime() - start; + uploadNanosLastFrame = took; + if (active && units >= 256 * 1024) { + double measured = (double) took / units; + nsPerUnit = Math.clamp(nsPerUnit * 0.8 + measured * 0.2, 0.02, 20.0); + } + heldLastFrame = held; + backlogFrames = held > 0 ? backlogFrames + 1 : 0; + active = false; + ChunkStaging.endFrame(); + } + + /** + * Render thread, at the start of one UberGpuBuffer.uploadStagedAllocations call (under the dispatcher's lock): + * moves the staged entries that don't fit in the time left from {@code staged} to {@code held}, so vanilla's + * code that follows uploads only the rest. Does nothing unless the budget applies to this call. + */ + public static void select(Object2ObjectOpenHashMap staged, Object2ObjectOpenHashMap held, + StagingBuffer staging) { + if (!active || staged.isEmpty()) { + return; + } + StagingBufferAccessor area = (StagingBufferAccessor) staging; + if (area.vesuvio$nextWriteOffset() > area.vesuvio$writeBuffer().capacity() * FLUSH_FILL) { + // Nearly full: send everything this frame (every call of the frame sees the same fill, nothing is + // staged while the dispatcher's lock is held), so the area starts over. + active = false; + return; + } + double left = (deadline - System.nanoTime()) / nsPerUnit; + long used = 0; + ObjectIterator> it = staged.object2ObjectEntrySet().fastIterator(); + while (it.hasNext()) { + Object2ObjectMap.Entry entry = it.next(); + long cost = ((StagedEntryAccessor) entry.getValue()).vesuvio$buffer().size() + ENTRY_COST_BYTES; + if (!sentAny || used + cost <= left) { + used += cost; + sentAny = true; + } else { + held.put(entry.getKey(), entry.getValue()); + it.remove(); + } + } + units += used; + } + + /** + * Render thread, inside the same call just before vanilla empties its "skipped" set: puts the held entries + * back for the next frame, except those whose mesh was released meanwhile (in {@code skipped}): those are + * closed, as vanilla closes skipped entries, so the staging area can be reused. + */ + public static void restore(Object2ObjectOpenHashMap staged, Object2ObjectOpenHashMap held, + ObjectOpenHashSet skipped) { + if (held.isEmpty()) { + return; + } + ObjectIterator> it = held.object2ObjectEntrySet().fastIterator(); + while (it.hasNext()) { + Object2ObjectMap.Entry entry = it.next(); + if (skipped.contains(entry.getKey())) { + ((StagedEntryAccessor) entry.getValue()).vesuvio$buffer().close(); + } else { + staged.put(entry.getKey(), entry.getValue()); + ChunkUploads.held++; + } + } + held.clear(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/MeshOrder.java b/src/main/java/it/cosimo/vesuvio/chunks/MeshOrder.java new file mode 100644 index 0000000..975482a --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/MeshOrder.java @@ -0,0 +1,47 @@ +package it.cosimo.vesuvio.chunks; + +import it.cosimo.vesuvio.VesuvioConfig; +import net.minecraft.client.renderer.chunk.CompiledSectionMesh; +import net.minecraft.client.renderer.chunk.SectionMesh; + +/** + * Keeps a section from going back to an older mesh. A finished mesh replaces the shown one either straight from + * the compile task (no geometry) or once all its buffers reached the GPU (RenderSection.checkSectionMesh). Vanilla + * uploads everything staged every frame, so an older build of a section (already staged when a newer task was + * made) normally lands first; with the upload budget an older mesh's buffers can wait while a newer build of the + * same section goes through, and an all-air section is emptied on the render thread while a worker may still be + * staging an older build. Here every candidate is compared with the compile start time of what the section shows: + * an older one is dropped (released, like vanilla releases a replaced mesh) instead of being shown over newer data. + * In vanilla's own order nothing changes (every accepted mesh is newer than the shown one). + */ +public final class MeshOrder { + private static final ThreadLocal TASK_START = ThreadLocal.withInitial(() -> new long[1]); + private static volatile long dropped; + + private MeshOrder() { + } + + /** Counter: outdated meshes dropped instead of shown since start-up. */ + public static long dropped() { + return dropped; + } + + /** Compile task or Vesuvio's empty-section path about to run on this thread, for meshes without a start time. */ + public static void taskStarted(long startNanos) { + TASK_START.get()[0] = startNanos; + } + + /** RenderSection.setSectionMesh (any thread): whether {@code mesh} may replace what the section shows. */ + public static boolean accept(SectionState section, SectionMesh mesh) { + if (!VesuvioConfig.ENABLED.on()) { + return true; + } + long start = mesh instanceof CompiledSectionMesh compiled ? compiled.getCompileTaskStartTime() : TASK_START.get()[0]; + if (start < section.vesuvio$shownStart()) { + dropped++; + return false; + } + section.vesuvio$setShownStart(start); + return true; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/SectionCopyTrim.java b/src/main/java/it/cosimo/vesuvio/chunks/SectionCopyTrim.java new file mode 100644 index 0000000..fcd3cc1 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/SectionCopyTrim.java @@ -0,0 +1,56 @@ +package it.cosimo.vesuvio.chunks; + +import java.util.Map; + +import com.google.common.collect.ImmutableMap; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.chunk.LevelChunk; + +/** + * Smaller section copies (render thread, SectionCopy's constructor). To build one section, vanilla copies its 3x3x3 + * neighbourhood (RenderRegionCache, once per section per frame), and every one of those copies takes an immutable + * copy of the whole chunk's block-entity map, all 24 sections' worth. A copy is only ever asked for block entities + * inside its own section (RenderSectionRegion.getBlockEntity picks the copy by the position's section), so keeping + * just those gives the same answers with a fraction of the garbage in chunks full of chests, signs or other block + * entities (and none when the section has no block entity: the shared empty map). + */ +public final class SectionCopyTrim { + private static long entriesSaved; + + private SectionCopyTrim() { + } + + /** Counter: block-entity map entries not copied since start-up. */ + public static long entriesSaved() { + return entriesSaved; + } + + /** Null when trimming is off (the caller then does vanilla's full copy). */ + public static ImmutableMap copyOwn(Map all, LevelChunk chunk, int sectionIndex) { + if (!ChunkUpdates.TRIM_COPIES.on()) { + return null; + } + if (all.isEmpty()) { + return ImmutableMap.of(); + } + int minY = SectionPos.sectionToBlockCoord(chunk.getSectionYFromSectionIndex(sectionIndex)); + int maxY = minY + 15; + ImmutableMap.Builder own = null; + int kept = 0; + for (Map.Entry entry : all.entrySet()) { + int y = entry.getKey().getY(); + if (y >= minY && y <= maxY) { + if (own == null) { + own = ImmutableMap.builder(); + } + own.put(entry.getKey(), entry.getValue()); + kept++; + } + } + entriesSaved += all.size() - kept; + return own == null ? ImmutableMap.of() : own.build(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/SectionState.java b/src/main/java/it/cosimo/vesuvio/chunks/SectionState.java new file mode 100644 index 0000000..7bd425b --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/SectionState.java @@ -0,0 +1,18 @@ +package it.cosimo.vesuvio.chunks; + +/** Per-section data Vesuvio adds to SectionRenderDispatcher.RenderSection (see RenderSectionStateMixin). */ +public interface SectionState { + /** Compile start time (Util.getNanos) of the mesh the section shows; newer results only may replace it. */ + long vesuvio$shownStart(); + + void vesuvio$setShownStart(long start); + + /** Camera position of the section's last translucent re-sort (NaN: none yet). Render thread only. */ + double vesuvio$sortX(); + + double vesuvio$sortY(); + + double vesuvio$sortZ(); + + void vesuvio$setSortPos(double x, double y, double z); +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/TranslucentSort.java b/src/main/java/it/cosimo/vesuvio/chunks/TranslucentSort.java new file mode 100644 index 0000000..88045e3 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/TranslucentSort.java @@ -0,0 +1,67 @@ +package it.cosimo.vesuvio.chunks; + +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.client.renderer.chunk.TranslucencyPointOfView; +import net.minecraft.core.BlockPos; +import net.minecraft.world.phys.Vec3; + +/** + * Fewer translucent re-sorts of far sections (render thread, LevelRenderer.scheduleResort). + * + *

Vanilla re-sorts a section's see-through faces (water, glass, ice...) when the camera crosses into another + * section-sized region around it (the "point of view" changes), and also on every one-block camera step for any + * section the camera is lined up with on one axis (same section row, column or layer: on a boat or at sea level that + * is a whole layer of ocean) and for the sections within about 32 blocks. Each re-sort is a task that sorts all the + * section's translucent quads and uploads a new index buffer, and it competes with chunk builds in the same queue. + * + *

The order of two faces only flips when the camera moves enough relative to their distance: from a section + * {@code d} blocks away, a one-block step changes the difference of two face distances in it by at most about + * {@code 28/d} blocks. Vanilla already lets the order go stale by up to one block of camera movement (it re-sorts + * on block changes), i.e. up to about 1.5 blocks of error for a section 32 blocks away. Here the one-block-step + * re-sort of a section farther than the nearby set happens once the camera moved at least {@code d/32} blocks since + * that section's last sort, which keeps the error at that same 32-block level at any distance (every 2 blocks at + * 64, every 8 at 256). Point-of-view changes, nearby sections and the first sort always go through as in vanilla. + */ +public final class TranslucentSort { + private static long done; + private static long skipped; + + private TranslucentSort() { + } + + /** Counter: translucent re-sorts let through since start-up. */ + public static long resortsDone() { + return done; + } + + /** Counter: translucent re-sorts saved since start-up. */ + public static long resortsSkipped() { + return skipped; + } + + /** Called where vanilla decided to re-sort {@code section}; false skips it this time. */ + public static boolean shouldResort(SectionRenderDispatcher.RenderSection section, TranslucencyPointOfView pointOfView, + Vec3 cameraPos, boolean isNearby) { + SectionState state = (SectionState) section; + if (ChunkUpdates.SORT_THROTTLE.on() && !isNearby && !section.getSectionMesh().isDifferentPointOfView(pointOfView)) { + double lastX = state.vesuvio$sortX(); + if (!Double.isNaN(lastX)) { + BlockPos origin = section.getRenderOrigin(); + double dx = origin.getX() + 8 - cameraPos.x; + double dy = origin.getY() + 8 - cameraPos.y; + double dz = origin.getZ() + 8 - cameraPos.z; + double mx = cameraPos.x - lastX; + double my = cameraPos.y - state.vesuvio$sortY(); + double mz = cameraPos.z - state.vesuvio$sortZ(); + // moved >= distance / 32 + if ((mx * mx + my * my + mz * mz) * 1024.0 < dx * dx + dy * dy + dz * dz) { + skipped++; + return false; + } + } + } + state.vesuvio$setSortPos(cameraPos.x, cameraPos.y, cameraPos.z); + done++; + return true; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/CompileTaskStartMixin.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/CompileTaskStartMixin.java new file mode 100644 index 0000000..47b1b58 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/CompileTaskStartMixin.java @@ -0,0 +1,29 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import it.cosimo.vesuvio.chunks.MeshOrder; +import net.minecraft.client.renderer.SectionBufferBuilderPack; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; + +/** + * SectionRenderDispatcher.RenderSection.CompileTask.doTask (worker thread, or the render thread for a synchronous + * build): tells {@link MeshOrder} this task's start time, which its result carries only when it has geometry. + */ +@Mixin(targets = "net.minecraft.client.renderer.chunk.SectionRenderDispatcher$RenderSection$CompileTask") +public abstract class CompileTaskStartMixin { + @Shadow + @Final + private long startTimeNs; + + @Inject(method = "doTask", at = @At("HEAD")) + private void vesuvio$noteStart(SectionBufferBuilderPack buffers, + CallbackInfoReturnable cir) { + MeshOrder.taskStarted(startTimeNs); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/ExtractSchedulingMixin.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/ExtractSchedulingMixin.java new file mode 100644 index 0000000..bafd83e --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/ExtractSchedulingMixin.java @@ -0,0 +1,50 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.chunks.ChunkScheduler; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import net.minecraft.client.Camera; +import net.minecraft.client.SectionUpdateTracker; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.chunk.RenderRegionCache; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.client.renderer.extract.LevelExtractor; +import net.minecraft.client.renderer.state.level.LevelRenderState; + +/** + * LevelExtractor.extract, "sectionUpdates" step (render thread): the list vanilla's loop walks to find dirty sections + * to rebuild is fetched with the one visibleSections() call of this method. {@link ChunkScheduler} does that loop's + * work itself (same conditions, same SectionUpdateRenderState entries) and hands vanilla an empty list; with both + * settings off it hands back the real list and vanilla's loop runs unchanged. + */ +@Mixin(LevelExtractor.class) +public abstract class ExtractSchedulingMixin { + @Shadow + private ClientLevel level; + @Shadow + private SectionUpdateTracker sectionUpdateTracker; + @Shadow + @Final + private LevelRenderState levelRenderState; + + @Shadow + @Final + private LevelRenderer levelRenderer; + + @ModifyExpressionValue(method = "extract", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/LevelRenderer;visibleSections()Lit/unimi/dsi/fastutil/objects/ObjectArrayList;")) + private ObjectArrayList vesuvio$scheduleSections( + ObjectArrayList visible, @Local(argsOnly = true) Camera camera, + @Local RenderRegionCache cache) { + return ChunkScheduler.schedule(visible, levelRenderer, level, sectionUpdateTracker, levelRenderState, + camera.position(), cache); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/RenderSectionInvoker.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/RenderSectionInvoker.java new file mode 100644 index 0000000..42e6df2 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/RenderSectionInvoker.java @@ -0,0 +1,20 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +import net.minecraft.client.renderer.chunk.SectionMesh; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; + +/** The RenderSection steps vanilla's compile task uses to show a finished (here: empty) mesh. */ +@Mixin(SectionRenderDispatcher.RenderSection.class) +public interface RenderSectionInvoker { + @Invoker("cancelTasks") + void vesuvio$cancelTasks(); + + @Invoker("setSectionMesh") + SectionMesh vesuvio$setSectionMesh(SectionMesh mesh); + + @Invoker("releaseSectionMesh") + void vesuvio$releaseSectionMesh(SectionMesh mesh); +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/RenderSectionStateMixin.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/RenderSectionStateMixin.java new file mode 100644 index 0000000..0cad3b2 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/RenderSectionStateMixin.java @@ -0,0 +1,78 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import it.cosimo.vesuvio.chunks.MeshOrder; +import it.cosimo.vesuvio.chunks.SectionState; +import net.minecraft.client.renderer.chunk.SectionMesh; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.util.Util; + +/** + * SectionRenderDispatcher.RenderSection: adds {@link SectionState} (compile time of the shown mesh, camera position + * of the last translucent re-sort) and the {@link MeshOrder} check in setSectionMesh. setSectionMesh's callers + * release whatever it returns (the replaced mesh); a refused mesh is returned instead, so it is released and the + * shown one stays. reset() (the section moved to a new position, or the world/geometry was reset) marks the time, + * so builds made for the old position can't be shown at the new one. + */ +@Mixin(SectionRenderDispatcher.RenderSection.class) +public abstract class RenderSectionStateMixin implements SectionState { + @Unique + private volatile long vesuvio$shownStart = Long.MIN_VALUE; + @Unique + private double vesuvio$sortX = Double.NaN; + @Unique + private double vesuvio$sortY = Double.NaN; + @Unique + private double vesuvio$sortZ = Double.NaN; + + @Inject(method = "setSectionMesh", at = @At("HEAD"), cancellable = true) + private void vesuvio$keepNewest(SectionMesh mesh, CallbackInfoReturnable cir) { + if (!MeshOrder.accept(this, mesh)) { + cir.setReturnValue(mesh); + } + } + + @Inject(method = "reset", at = @At("TAIL")) + private void vesuvio$resetOrder(CallbackInfo ci) { + vesuvio$shownStart = Util.getNanos(); + vesuvio$sortX = Double.NaN; + } + + @Override + public long vesuvio$shownStart() { + return vesuvio$shownStart; + } + + @Override + public void vesuvio$setShownStart(long start) { + vesuvio$shownStart = start; + } + + @Override + public double vesuvio$sortX() { + return vesuvio$sortX; + } + + @Override + public double vesuvio$sortY() { + return vesuvio$sortY; + } + + @Override + public double vesuvio$sortZ() { + return vesuvio$sortZ; + } + + @Override + public void vesuvio$setSortPos(double x, double y, double z) { + vesuvio$sortX = x; + vesuvio$sortY = y; + vesuvio$sortZ = z; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/RenderedAllMixin.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/RenderedAllMixin.java new file mode 100644 index 0000000..e3f3386 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/RenderedAllMixin.java @@ -0,0 +1,21 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; + +import it.cosimo.vesuvio.chunks.ChunkScheduler; +import net.minecraft.client.renderer.LevelRenderer; + +/** + * LevelRenderer.hasRenderedAllSections (used for the world-list screenshot): with the scheduler, an empty compile + * queue no longer means nothing is pending, as dirty sections in view may still wait for their turn. + */ +@Mixin(LevelRenderer.class) +public abstract class RenderedAllMixin { + @ModifyReturnValue(method = "hasRenderedAllSections", at = @At("RETURN")) + private boolean vesuvio$includeDeferred(boolean original) { + return original && ChunkScheduler.nothingDeferred(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/ResortThrottleMixin.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/ResortThrottleMixin.java new file mode 100644 index 0000000..2ba6f2f --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/ResortThrottleMixin.java @@ -0,0 +1,28 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.chunks.TranslucentSort; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.client.renderer.chunk.TranslucencyPointOfView; +import net.minecraft.world.phys.Vec3; + +/** + * LevelRenderer.scheduleResort (private, render thread): reached only where vanilla decided to re-sort; with the + * setting on, {@link TranslucentSort} may postpone the re-sort of a far section the camera only moved a little for. + */ +@Mixin(LevelRenderer.class) +public abstract class ResortThrottleMixin { + @WrapWithCondition(method = "scheduleResort", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/chunk/SectionRenderDispatcher$RenderSection;resortTransparency()V")) + private boolean vesuvio$throttleResort(SectionRenderDispatcher.RenderSection section, + @Local(argsOnly = true) TranslucencyPointOfView pointOfView, @Local(argsOnly = true) Vec3 cameraPos, + @Local(argsOnly = true, ordinal = 1) boolean isNearby) { + return TranslucentSort.shouldResort(section, pointOfView, cameraPos, isNearby); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/SectionCopyTrimMixin.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/SectionCopyTrimMixin.java new file mode 100644 index 0000000..e84fd07 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/SectionCopyTrimMixin.java @@ -0,0 +1,30 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import java.util.Map; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.google.common.collect.ImmutableMap; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.chunks.SectionCopyTrim; +import net.minecraft.client.renderer.chunk.SectionCopy; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.chunk.LevelChunk; + +/** SectionCopy.<init>: copy only the section's own block entities ({@link SectionCopyTrim}). */ +@Mixin(SectionCopy.class) +public abstract class SectionCopyTrimMixin { + @WrapOperation(method = "", at = @At(value = "INVOKE", + target = "Lcom/google/common/collect/ImmutableMap;copyOf(Ljava/util/Map;)Lcom/google/common/collect/ImmutableMap;")) + private ImmutableMap vesuvio$copyOwnBlockEntities(Map all, + Operation> original, @Local(argsOnly = true) LevelChunk chunk, + @Local(argsOnly = true) int sectionIndex) { + ImmutableMap own = SectionCopyTrim.copyOwn(all, chunk, sectionIndex); + return own != null ? own : original.call(all); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagedEntryAccessor.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagedEntryAccessor.java new file mode 100644 index 0000000..ce166cd --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagedEntryAccessor.java @@ -0,0 +1,13 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import com.mojang.blaze3d.vertex.StagingBuffer; + +/** UberGpuBuffer.StagedAllocationEntry (private record): its staging handle, for size and closing. */ +@Mixin(targets = "com.mojang.blaze3d.vertex.UberGpuBuffer$StagedAllocationEntry") +public interface StagedEntryAccessor { + @Accessor("buffer") + StagingBuffer.BufferHandle vesuvio$buffer(); +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagingBufferAccessor.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagingBufferAccessor.java new file mode 100644 index 0000000..edbf0c8 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagingBufferAccessor.java @@ -0,0 +1,19 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import java.nio.ByteBuffer; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +import com.mojang.blaze3d.vertex.StagingBuffer; + +/** StagingBuffer: how much of the staging area is in use (it is only reused once every staged buffer was sent). */ +@Mixin(StagingBuffer.class) +public interface StagingBufferAccessor { + @Accessor("nextWriteOffset") + int vesuvio$nextWriteOffset(); + + @Invoker("getWriteBuffer") + ByteBuffer vesuvio$writeBuffer(); +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagingCpuMixin.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagingCpuMixin.java new file mode 100644 index 0000000..21e40a7 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagingCpuMixin.java @@ -0,0 +1,63 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import java.nio.ByteBuffer; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.mojang.renderpearl.api.buffers.GpuBuffer; +import com.mojang.renderpearl.api.commands.CommandEncoder; + +import it.cosimo.vesuvio.chunks.ChunkStaging; +import it.cosimo.vesuvio.chunks.ChunkStagingHost; + +/** + * StagingBuffer.Cpu (vanilla's chunk staging area on Vulkan), setting mappedChunkStaging ({@link ChunkStaging}): + * while its mapped stand-in is in use, the workers write into the mapped buffer and the upload is one copyToBuffer + * from it instead of writeToBuffer (a second memcpy into transient memory, then the same GPU copy). Without a + * stand-in, or while it is unmapped, every method is vanilla. + */ +@Mixin(targets = "com.mojang.blaze3d.vertex.StagingBuffer$Cpu") +public abstract class StagingCpuMixin implements ChunkStagingHost { + @Unique + private ChunkStaging.Area vesuvio$area; + + @Override + public ChunkStaging.Area vesuvio$stagingArea() { + return this.vesuvio$area; + } + + @Override + public void vesuvio$setStagingArea(ChunkStaging.Area area) { + this.vesuvio$area = area; + } + + @ModifyReturnValue(method = "getWriteBuffer", at = @At("RETURN")) + private ByteBuffer vesuvio$mappedWriteBuffer(ByteBuffer original) { + ChunkStaging.Area area = this.vesuvio$area; + ByteBuffer mapped = area != null ? area.data() : null; + return mapped != null ? mapped : original; + } + + @Inject(method = "copyTo", at = @At("HEAD"), cancellable = true) + private void vesuvio$copyFromMapped(CommandEncoder encoder, GpuBuffer dstBuffer, long dstOffset, + long stagingBufferOffset, long copySize, CallbackInfo ci) { + ChunkStaging.Area area = this.vesuvio$area; + if (area != null && area.mapped()) { + area.copy(encoder, dstBuffer, dstOffset, stagingBufferOffset, copySize); + ci.cancel(); + } + } + + @Inject(method = "close", at = @At("HEAD")) + private void vesuvio$closeMapped(CallbackInfo ci) { + if (this.vesuvio$area != null) { + this.vesuvio$area.close(); + this.vesuvio$area = null; + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagingMappedMixin.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagingMappedMixin.java new file mode 100644 index 0000000..a2e4f21 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/StagingMappedMixin.java @@ -0,0 +1,56 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.mojang.blaze3d.vertex.StagingBuffer; +import com.mojang.renderpearl.api.commands.CommandEncoder; +import com.mojang.renderpearl.api.device.GpuDevice; + +import it.cosimo.vesuvio.chunks.ChunkStaging; +import it.cosimo.vesuvio.chunks.ChunkStagingHost; + +/** + * StagingBuffer, setting mappedChunkStaging ({@link ChunkStaging}): gives the chunk staging area a mapped stand-in + * when it is created, and lets the stand-in follow the setting only where nothing is staged: at vanilla's + * rotateBuffer point (everything sent, the area starts over) and at the start of an upload with nothing written. + * Staging areas other than the game's "Chunk" one, and vanilla's own mapped variant, are left alone. + */ +@Mixin(StagingBuffer.class) +public abstract class StagingMappedMixin { + @Shadow + private int nextWriteOffset; + + @ModifyReturnValue(method = "create", at = @At("RETURN")) + private static StagingBuffer vesuvio$attachMappedArea(StagingBuffer staging, String name, GpuDevice device, + int bufferSize) { + if ("Chunk".equals(name) && staging instanceof ChunkStagingHost host + && device.getDeviceInfo().features().persistentMapping()) { + ChunkStaging.Area area = new ChunkStaging.Area(name, bufferSize); + host.vesuvio$setStagingArea(area); + area.sync(); + } + return staging; + } + + @Inject(method = "startUploading", at = @At("HEAD")) + private void vesuvio$followSetting(CommandEncoder encoder, CallbackInfoReturnable cir) { + if (this.nextWriteOffset == 0 && (Object) this instanceof ChunkStagingHost host + && host.vesuvio$stagingArea() != null) { + host.vesuvio$stagingArea().sync(); + } + } + + // Base-class method (the Cpu area does not override it): reached right before the write offset goes back to 0. + @Inject(method = "rotateBuffer", at = @At("HEAD")) + private void vesuvio$restartMappedArea(CallbackInfo ci) { + if ((Object) this instanceof ChunkStagingHost host && host.vesuvio$stagingArea() != null) { + host.vesuvio$stagingArea().restart(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/UberBufferBudgetMixin.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/UberBufferBudgetMixin.java new file mode 100644 index 0000000..56baef2 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/UberBufferBudgetMixin.java @@ -0,0 +1,53 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.blaze3d.vertex.StagingBuffer; +import com.mojang.blaze3d.vertex.UberGpuBuffer; +import com.mojang.renderpearl.api.device.GpuDevice; + +import it.cosimo.vesuvio.chunks.ChunkUploads; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; + +/** + * UberGpuBuffer.uploadStagedAllocations (render thread, under the chunk dispatcher's lock): with the upload budget + * active ({@link ChunkUploads}), the staged entries that don't fit this frame are set aside before vanilla's code + * runs and put back just before it empties its "skipped" set, so vanilla allocates, copies and calls back exactly as + * usual, only for fewer entries. Entries of meshes released meanwhile are closed instead of put back, as vanilla + * would have skipped them. With the budget off nothing is set aside and both hooks do nothing. + */ +@Mixin(UberGpuBuffer.class) +public abstract class UberBufferBudgetMixin { + @Shadow + @Final + private Object2ObjectOpenHashMap stagedAllocations; + + @Shadow + @Final + private StagingBuffer stagingBuffer; + + @Unique + private final Object2ObjectOpenHashMap vesuvio$held = new Object2ObjectOpenHashMap<>(); + + @Inject(method = "uploadStagedAllocations", at = @At("HEAD")) + private void vesuvio$selectWithinBudget(GpuDevice device, StagingBuffer.Uploader uploader, + CallbackInfoReturnable cir) { + ChunkUploads.select(stagedAllocations, vesuvio$held, stagingBuffer); + } + + @WrapOperation(method = "uploadStagedAllocations", at = @At(value = "INVOKE", + target = "Lit/unimi/dsi/fastutil/objects/ObjectOpenHashSet;clear()V")) + private void vesuvio$restoreHeld(ObjectOpenHashSet skipped, Operation original) { + ChunkUploads.restore(stagedAllocations, vesuvio$held, skipped); + original.call(skipped); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/chunks/mixin/UploadBudgetMixin.java b/src/main/java/it/cosimo/vesuvio/chunks/mixin/UploadBudgetMixin.java new file mode 100644 index 0000000..212f412 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/chunks/mixin/UploadBudgetMixin.java @@ -0,0 +1,29 @@ +package it.cosimo.vesuvio.chunks.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; + +import it.cosimo.vesuvio.chunks.ChunkUploads; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; + +/** + * LevelRenderer.render: the end-of-frame chunk upload is the only one the budget applies to. The other caller, + * a synchronous build that found the staging area full, still sends everything at once as in vanilla. + */ +@Mixin(LevelRenderer.class) +public abstract class UploadBudgetMixin { + @WrapOperation(method = "render", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/chunk/SectionRenderDispatcher;uploadTerrainBuffersToGpu()V")) + private void vesuvio$budgetedUpload(SectionRenderDispatcher dispatcher, Operation original) { + ChunkUploads.beginFrame(); + try { + original.call(dispatcher); + } finally { + ChunkUploads.endFrame(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/ClearFusion.java b/src/main/java/it/cosimo/vesuvio/clears/ClearFusion.java new file mode 100644 index 0000000..5f40eb8 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/ClearFusion.java @@ -0,0 +1,569 @@ +package it.cosimo.vesuvio.clears; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalDouble; + +import org.joml.Vector4f; +import org.joml.Vector4fc; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.vulkan.VK12; +import org.lwjgl.vulkan.VkCommandBuffer; +import org.lwjgl.vulkan.VkRect2D; +import org.lwjgl.vulkan.VkRenderPassBeginInfo; +import org.lwjgl.vulkan.VkSubpassBeginInfo; +import org.lwjgl.vulkan.VkSubpassEndInfo; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.renderpearl.api.commands.CommandEncoder; +import com.mojang.renderpearl.api.commands.RenderPass; +import com.mojang.renderpearl.api.commands.RenderPassDescriptor; +import com.mojang.renderpearl.api.textures.GpuTexture; +import com.mojang.renderpearl.api.textures.GpuTextureView; +import com.mojang.renderpearl.backend.api.CommandEncoderBackend; +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; +import com.mojang.renderpearl.frontend.FrontendCommandEncoder; + +import it.cosimo.vesuvio.Vesuvio; +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.clears.mixin.ClearsVulkanEncoderAccessor; +import it.cosimo.vesuvio.ui.VesuvioStats; + +/** + * Clear fusion and dead-clear removal (research-vulkan-engine.md, T1). + * + *

A texture clear issued through the command encoder (clearColorTexture, clearDepthTexture, + * clearColorAndDepthTextures) is a separate Vulkan command: vkCmdClearColorImage or vkCmdClearDepthStencilImage + * plus a full barrier. On MoltenVK each one is its own Metal render encoder that writes the whole image out, and + * the render pass that comes next then reads it all back (vanilla's passes only clear in their load op when the + * caller gives them a clear value). Post chains clear every target they take from the frame's pool, the hand and + * the GUI clear the main depth, and so on. + * + *

Here such a clear is not sent at once: it is noted as pending (texture, value). What happens next decides + * what becomes of it, always with the same pixels as vanilla: + *

    + *
  • Fused. The next render pass attaches the texture, would load it (no clear value of its own) and + * covers all of it (render area = whole texture, one mip level, one layer). The pass gets the pending value as + * its clear value, so the backend picks LOAD_OP_CLEAR and the pixels start as the same value, with one full + * write and one full read less. The value goes through the same conversion as the separate clear + * (VulkanUtils.putArgb for colour, a float for depth). Depth formats with stencil are never fused (the separate + * depth clear leaves stencil alone; a load op would too, but it is not worth relying on), they are sent.
  • + *
  • Dropped. The texture is cleared again, or a pass clears all of it in its own load op, or the texture + * is destroyed, and nothing looked at it in between: the pending clear could never have been seen.
  • + *
  • Sent (flushed), as vanilla would have, just before anything that could see the texture: a pass that + * attaches it in a way that cannot be fused (part of it, another mip, with stencil, twice), a write or copy into + * or out of it, the copy to the window, the end of the frame. A pass that does not attach it may still sample + * it, so every pending clear is sent before a pass begins, except the dead candidates below.
  • + *
+ * + *

Dead candidates. The two GUI depth clears (GameRenderer.render before the GUI, GuiRenderer.draw before + * the menu blur) clear the main depth for a GUI that has no depth attachment: nothing reads it until the next + * frame's main pass clears it again. These two survive passes that do not attach the main depth, and the end of + * the frame too, so they normally end dropped (by the next frame's clear). Nothing is assumed about the passes in + * between, including mods' passes: a pass that attaches the main depth fuses it (or gets it sent first), any + * transfer that touches it sends it, and a pass that binds it as a texture to sample (FrontendRenderPass + * setUniform, the only way a pass reads a texture that is not one of its attachments) gets it right there: the + * open Vulkan render pass is ended (store), the clear and its barriers are recorded, and the same pass is begun + * again on the same framebuffer with every attachment loaded. Draws already recorded did not use the texture + * (it was not bound yet), Vulkan keeps bound pipeline, descriptor sets, buffers and dynamic state across render + * pass instances in one command buffer, so later draws see the cleared texture exactly as in vanilla. That split + * costs one store and load of the pass's attachments, so it is kept for clears that are dead in practice + * (counted as "clears split", and logged once). + * + *

Post-chain depth diet (ClearsPostChainMixin): every internal target of a post chain gets a depth + * texture that vanilla allocates and clears every frame, though post passes are colour-only and sample a + * target's depth only through an input with use_depth_buffer. Targets that no input of the chain reads as depth + * get none, while this setting is on. + * + *

Only the Vulkan backend and only clears made on the render thread are deferred. A clear made on another + * thread runs as vanilla (after dropping a pending clear of the same texture, which it overwrites); any other + * command on any thread first sends the pending clears it touches. The table is locked, so another thread never + * sees it half changed. With the setting (or the master switch) off nothing new is deferred and whatever is + * still pending is sent at the next command that touches the encoder. + */ +public final class ClearFusion { + public static final VesuvioConfig.Toggle CLEAR_FUSION = VesuvioConfig.toggle("rendering", "clearFusion", true, false); + + // Counters for the settings screen and benchmark, last frame (render thread). + /** Clears turned into a render pass load op. */ + public static int fusedLastFrame; + /** Clears that were never sent: overwritten or destroyed before anything could see them. */ + public static int droppedLastFrame; + /** Dead-candidate clears a pass sampled after all, recorded in the middle of that pass (see class doc). */ + public static int splitLastFrame; + + private static final Object LOCK = new Object(); + /** Clears not sent yet, at most one per texture. A handful at a time, so a list beats a map. */ + private static final ArrayList PENDING = new ArrayList<>(); + /** PENDING.size(), readable without the lock (the per-bind check reads it first). */ + private static volatile int pendingCount; + private static int fused; + private static int dropped; + private static int split; + private static boolean splitLogged; + + /** Set by the GUI clear call sites around their clearDepthTexture call (render thread). */ + private static boolean candidateMark; + + // The last Vulkan render pass begun (render thread): what a split needs to begin it again. + private static RenderPassDescriptor beganDescriptor; + private static long beganFramebuffer; + private static int beganX; + private static int beganY; + private static int beganWidth; + private static int beganHeight; + + private ClearFusion() { + } + + private static final class Pending { + final GpuTexture texture; + final boolean depth; + /** Copied: callers may change their vector after the call. */ + final Vector4f color; + final double depthValue; + final boolean deadCandidate; + + Pending(GpuTexture texture, boolean depth, Vector4f color, double depthValue, boolean deadCandidate) { + this.texture = texture; + this.depth = depth; + this.color = color; + this.depthValue = depthValue; + this.deadCandidate = deadCandidate; + } + } + + /** Called from Vesuvio.onInitializeClient: makes the setting and registers the counters. */ + public static void init() { + VesuvioStats.register("clears fused", () -> fusedLastFrame); + VesuvioStats.register("clears dropped", () -> droppedLastFrame); + VesuvioStats.register("clears split", () -> splitLastFrame); + } + + // ---- clears (FrontendCommandEncoder, after its checks, instead of the backend call) ---- + + /** The GUI call sites mark their depth clear as a dead candidate (render thread). */ + public static void markDeadCandidate() { + candidateMark = true; + } + + public static void clearDeadCandidateMark() { + candidateMark = false; + } + + /** Whether a new clear may be held back: the setting, the Vulkan backend, the render thread. */ + private static boolean mayDefer(CommandEncoderBackend backend) { + return CLEAR_FUSION.on() && backend instanceof VulkanCommandEncoder && RenderSystem.isOnRenderThread(); + } + + /** A colour clear: true when it was held back (the caller then skips the backend call). */ + public static boolean deferColor(CommandEncoderBackend backend, GpuTexture texture, Vector4fc color) { + boolean defer = mayDefer(backend); + if (!defer && pendingCount == 0) { + return false; + } + synchronized (LOCK) { + supersede(texture); + if (defer) { + add(new Pending(texture, false, new Vector4f(color), 0.0, false)); + } + return defer; + } + } + + /** A depth clear: true when it was held back. */ + public static boolean deferDepth(CommandEncoderBackend backend, GpuTexture texture, double depth) { + boolean candidate = candidateMark; + candidateMark = false; + boolean defer = mayDefer(backend); + if (!defer && pendingCount == 0) { + return false; + } + synchronized (LOCK) { + supersede(texture); + if (defer) { + add(new Pending(texture, true, null, depth, candidate)); + } + return defer; + } + } + + /** Both at once (one call in vanilla): both held back, or neither. */ + public static boolean deferColorAndDepth(CommandEncoderBackend backend, GpuTexture colorTexture, Vector4fc color, + GpuTexture depthTexture, double depth) { + boolean defer = mayDefer(backend); + if (!defer && pendingCount == 0) { + return false; + } + synchronized (LOCK) { + supersede(colorTexture); + supersede(depthTexture); + if (defer) { + add(new Pending(colorTexture, false, new Vector4f(color), 0.0, false)); + add(new Pending(depthTexture, true, null, depth, false)); + } + return defer; + } + } + + /** + * A new clear of the texture overwrites every pixel of every mip level, so a clear still pending for it is + * dead: anything that could have seen it would have sent it already. Under LOCK. + */ + private static void supersede(GpuTexture texture) { + if (remove(texture) != null) { + dropped++; + } + } + + // ---- render passes (FrontendCommandEncoder.createRenderPass, after its checks) ---- + + /** + * Settles the pending clears before a render pass begins: fuses the ones it can take as load-op clears, + * drops the ones it overwrites, sends every other one except surviving dead candidates. Returns the descriptor + * to begin the pass with (the same one when nothing was fused). + */ + public static RenderPassDescriptor beforePass(CommandEncoderBackend backend, RenderPassDescriptor descriptor) { + if (pendingCount == 0) { + return descriptor; + } + synchronized (LOCK) { + boolean on = CLEAR_FUSION.on(); + // Made only when something must be sent: most passes in the GUI phase find only a dead candidate. + List send = null; + RenderPass.RenderArea area = descriptor.renderArea(); + List>> colors = descriptor.colorAttachments(); + List>> newColors = null; + for (int i = 0; i < colors.size(); i++) { + RenderPassDescriptor.Attachment> attachment = colors.get(i); + if (attachment == null) { + continue; + } + GpuTexture texture = attachment.textureView().texture(); + Pending pending = remove(texture); + if (pending == null) { + continue; + } + if (on && !pending.depth && coversAll(attachment.textureView(), area) && attachedOnce(descriptor, texture)) { + if (attachment.clearValue().isPresent()) { + dropped++; + } else { + if (newColors == null) { + newColors = new ArrayList<>(colors); + } + newColors.set(i, new RenderPassDescriptor.Attachment<>(attachment.textureView(), + Optional.of(new Vector4f(pending.color)))); + fused++; + } + } else { + send = sendLater(send, pending); + } + } + RenderPassDescriptor.Attachment depthAttachment = descriptor.depthAttachment(); + RenderPassDescriptor.Attachment newDepth = depthAttachment; + if (depthAttachment != null) { + GpuTexture texture = depthAttachment.textureView().texture(); + Pending pending = remove(texture); + if (pending != null) { + if (on && pending.depth && coversAll(depthAttachment.textureView(), area) + && attachedOnce(descriptor, texture)) { + if (depthAttachment.clearValue().isPresent()) { + // The pass's own depth clear writes every depth value; stencil is left alone either way. + dropped++; + } else if (!texture.getFormat().hasStencilAspect()) { + newDepth = new RenderPassDescriptor.Attachment<>(depthAttachment.textureView(), + OptionalDouble.of(pending.depthValue)); + fused++; + } else { + send = sendLater(send, pending); + } + } else { + send = sendLater(send, pending); + } + } + } + // The rest is not attached; the pass may sample it. Only dead candidates wait (split if sampled). + for (int i = PENDING.size() - 1; i >= 0; i--) { + Pending pending = PENDING.get(i); + if (!on || !pending.deadCandidate) { + PENDING.remove(i); + send = sendLater(send, pending); + } + } + pendingCount = PENDING.size(); + if (send != null) { + emit(backend, send); + } + if (newColors == null && newDepth == depthAttachment) { + return descriptor; + } + return new RenderPassDescriptor(descriptor.label(), newColors != null ? newColors : colors, newDepth, area); + } + } + + /** + * Whether a load-op clear of this attachment writes exactly what the separate clear writes: the whole + * texture (one mip level, one layer, the view starts at mip 0) and a render area covering all of it. + */ + private static boolean coversAll(GpuTextureView view, RenderPass.RenderArea area) { + GpuTexture texture = view.texture(); + return texture.getMipLevels() == 1 && texture.getDepthOrLayers() == 1 && view.baseMipLevel() == 0 + && area.x() == 0 && area.y() == 0 + && area.width() == texture.getWidth(0) && area.height() == texture.getHeight(0); + } + + /** A texture attached in two slots (not allowed by Vulkan anyway) is never fused. */ + private static boolean attachedOnce(RenderPassDescriptor descriptor, GpuTexture texture) { + int n = 0; + for (RenderPassDescriptor.Attachment> attachment : descriptor.colorAttachments()) { + if (attachment != null && attachment.textureView().texture() == texture) { + n++; + } + } + if (descriptor.depthAttachment() != null && descriptor.depthAttachment().textureView().texture() == texture) { + n++; + } + return n == 1; + } + + // ---- sampling inside a pass (FrontendRenderPass.setUniform) ---- + + /** A pass binds a texture to sample: a dead candidate still pending for it is recorded right here. */ + public static void beforeSample(GpuTextureView view) { + if (pendingCount == 0) { + return; + } + Pending pending; + synchronized (LOCK) { + pending = remove(view.texture()); + pendingCount = PENDING.size(); + } + if (pending == null) { + return; + } + CommandEncoder encoder = RenderSystem.getDevice().createCommandEncoder(); + CommandEncoderBackend backend = encoder instanceof FrontendCommandEncoder frontend ? frontend.backend() : null; + if (backend instanceof VulkanCommandEncoder vulkan && encoder instanceof FrontendCommandEncoder frontend + && frontend.isInRenderPass() && ((ClearsVulkanEncoderAccessor) vulkan).vesuvio$currentRenderPass() != null + && beganDescriptor != null) { + splitPass(vulkan, pending); + } else if (backend != null) { + // Not inside a pass after all (a stale pass object): an ordinary clear is allowed and in order. + List one = new ArrayList<>(1); + one.add(pending); + emit(backend, one); + } + } + + /** + * Ends the open Vulkan render pass, records the clear with its barriers, and begins the same pass again + * (same framebuffer, same render area, every attachment loaded). See the class doc for why this is exact. + */ + private static void splitPass(VulkanCommandEncoder encoder, Pending pending) { + ClearsVulkanEncoderAccessor access = (ClearsVulkanEncoderAccessor) encoder; + VkCommandBuffer commandBuffer = access.vesuvio$commandBuffer(); + long loadPass = access.vesuvio$device().renderpassCache().getRenderPass(loadOnly(beganDescriptor)); + try (MemoryStack stack = MemoryStack.stackPush()) { + VK12.vkCmdEndRenderPass2(commandBuffer, VkSubpassEndInfo.calloc(stack).sType$Default()); + VulkanCommandEncoder.memoryBarrier(commandBuffer, stack); + if (pending.depth) { + encoder.clearDepthTextureUnsynced(stack, pending.texture, pending.depthValue); + } else { + access.vesuvio$clearColorTextureUnsynced(stack, pending.texture, pending.color); + } + VulkanCommandEncoder.memoryBarrier(commandBuffer, stack); + VkRect2D area = VkRect2D.calloc(stack); + area.offset().set(beganX, beganY); + area.extent().set(beganWidth, beganHeight); + VkRenderPassBeginInfo beginInfo = VkRenderPassBeginInfo.calloc(stack).sType$Default(); + beginInfo.renderPass(loadPass); + beginInfo.framebuffer(beganFramebuffer); + beginInfo.renderArea(area); + VkSubpassBeginInfo subpassBeginInfo = VkSubpassBeginInfo.calloc(stack).sType$Default(); + subpassBeginInfo.contents(0); + VK12.vkCmdBeginRenderPass2(commandBuffer, beginInfo, subpassBeginInfo); + } + split++; + if (!splitLogged) { + splitLogged = true; + Vesuvio.LOGGER.info("Vesuvio clearFusion: a pass sampled {} while its clear was held back; cleared inside the pass" + + " (exact, a little slower; counted as \"clears split\")", pending.texture.getLabel()); + } + } + + /** The same attachments with no clear values: every one loaded (the framebuffer stays compatible). */ + private static RenderPassDescriptor loadOnly(RenderPassDescriptor descriptor) { + List>> colors = new ArrayList<>(descriptor.colorAttachments().size()); + for (RenderPassDescriptor.Attachment> attachment : descriptor.colorAttachments()) { + colors.add(attachment == null ? null : new RenderPassDescriptor.Attachment<>(attachment.textureView(), Optional.empty())); + } + RenderPassDescriptor.Attachment depth = descriptor.depthAttachment() == null ? null + : new RenderPassDescriptor.Attachment<>(descriptor.depthAttachment().textureView(), OptionalDouble.empty()); + return new RenderPassDescriptor(descriptor.label(), colors, depth, descriptor.renderArea()); + } + + /** VulkanCommandEncoder.createRenderPass, as it begins the pass: remembered for a split. Render thread. */ + public static void onVulkanPassBegin(RenderPassDescriptor descriptor, VkRenderPassBeginInfo beginInfo) { + beganDescriptor = descriptor; + beganFramebuffer = beginInfo.framebuffer(); + VkRect2D area = beginInfo.renderArea(); + beganX = area.offset().x(); + beganY = area.offset().y(); + beganWidth = area.extent().width(); + beganHeight = area.extent().height(); + } + + /** Drops the remembered pass (its descriptor holds the pass's texture views). Render thread, outside any pass. */ + private static void forgetPass() { + beganDescriptor = null; + beganFramebuffer = 0L; + } + + /** + * The world is changing or being left (Vesuvio.levelChanged, render thread, outside any pass): lets go of the last + * pass's attachments. Pending clears stay: they are sent, fused or dropped as usual (only the GUI's dead depth + * clears of the main target can still be pending here, and the main target outlives the world). + */ + public static void release() { + forgetPass(); + } + + // ---- everything else that can see a texture ---- + + /** A write, copy or read of these textures (either may be null) is about to be recorded: send their clears first. */ + public static void beforeTransfer(CommandEncoderBackend backend, GpuTexture first, GpuTexture second) { + if (pendingCount == 0) { + return; + } + synchronized (LOCK) { + Pending a = first != null ? remove(first) : null; + Pending b = second != null ? remove(second) : null; + boolean on = CLEAR_FUSION.on(); + if (a == null && b == null && on) { + // Nothing of these textures pending (the usual case while only a dead candidate waits). + return; + } + List send = new ArrayList<>(2); + if (a != null) { + send.add(a); + } + if (b != null) { + send.add(b); + } + if (!on) { + // Switched off: send everything now rather than keep any of it. + send.addAll(PENDING); + PENDING.clear(); + } + pendingCount = PENDING.size(); + emit(backend, send); + } + } + + /** + * The frame is about to be submitted: every pending clear is sent, except dead candidates, which stay for the + * next frame (its main pass normally clears the same depth again and drops them; anything that looks at the + * texture first sends or splits them, so carrying them over is the same as sending them now). + */ + public static void beforeSubmit(CommandEncoderBackend backend) { + if (pendingCount != 0) { + synchronized (LOCK) { + boolean on = CLEAR_FUSION.on() && RenderSystem.isOnRenderThread(); + List send = null; + for (int i = PENDING.size() - 1; i >= 0; i--) { + Pending pending = PENDING.get(i); + if (!on || !pending.deadCandidate) { + PENDING.remove(i); + send = sendLater(send, pending); + } + } + pendingCount = PENDING.size(); + if (send != null) { + emit(backend, send); + } + } + } + // No pass is open at a submit, so nothing can split the last one any more: let go of its attachments. + forgetPass(); + fusedLastFrame = fused; + droppedLastFrame = dropped; + splitLastFrame = split; + fused = 0; + dropped = 0; + split = 0; + } + + /** The texture's image is about to be destroyed (closed, last view closed): nothing can see its clear any more. */ + public static void onTextureDestroyed(GpuTexture texture) { + if (pendingCount == 0) { + return; + } + synchronized (LOCK) { + if (remove(texture) != null) { + dropped++; + } + pendingCount = PENDING.size(); + } + } + + // ---- table ---- + + /** Adds a clear to the list of those to send, making the list on first use. */ + private static List sendLater(List send, Pending pending) { + List list = send != null ? send : new ArrayList<>(4); + list.add(pending); + return list; + } + + private static void add(Pending pending) { + PENDING.add(pending); + pendingCount = PENDING.size(); + } + + private static Pending remove(GpuTexture texture) { + for (int i = 0; i < PENDING.size(); i++) { + if (PENDING.get(i).texture == texture) { + Pending pending = PENDING.remove(i); + pendingCount = PENDING.size(); + return pending; + } + } + return null; + } + + /** + * Records the clears as vanilla would have (a colour and a depth clear with the same mip count go as one + * clearColorAndDepthTextures, one barrier: the same as two calls, the images are different). Outside any pass. + */ + private static void emit(CommandEncoderBackend backend, List send) { + int n = send.size(); + for (int i = 0; i < n; i++) { + Pending p = send.get(i); + if (p == null) { + continue; + } + if (!p.depth) { + for (int j = 0; j < n; j++) { + Pending q = send.get(j); + if (q != null && q.depth && q.texture.getMipLevels() == p.texture.getMipLevels()) { + send.set(j, null); + send.set(i, null); + backend.clearColorAndDepthTextures(p.texture, p.color, q.texture, q.depthValue); + break; + } + } + if (send.get(i) != null) { + send.set(i, null); + backend.clearColorTexture(p.texture, p.color); + } + } + } + for (int i = 0; i < n; i++) { + Pending p = send.get(i); + if (p != null) { + backend.clearDepthTexture(p.texture, p.depthValue); + } + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsFrontendEncoderMixin.java b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsFrontendEncoderMixin.java new file mode 100644 index 0000000..49957c4 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsFrontendEncoderMixin.java @@ -0,0 +1,99 @@ +package it.cosimo.vesuvio.clears.mixin; + +import java.nio.ByteBuffer; + +import org.joml.Vector4fc; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.renderpearl.api.buffers.GpuBuffer; +import com.mojang.renderpearl.api.buffers.GpuBufferSlice; +import com.mojang.renderpearl.api.commands.RenderPassDescriptor; +import com.mojang.renderpearl.api.textures.GpuTexture; +import com.mojang.renderpearl.backend.api.CommandEncoderBackend; +import com.mojang.renderpearl.backend.api.RenderPassBackend; +import com.mojang.renderpearl.frontend.FrontendCommandEncoder; + +import it.cosimo.vesuvio.clears.ClearFusion; + +/** + * Setting clearFusion: the command encoder's front end, where every caller's clears, passes and copies arrive + * (vanilla's, Etna's and other mods'). Each hook sits on the backend call, after the front end's own checks, so + * a call that would throw still throws the same way. + */ +@Mixin(FrontendCommandEncoder.class) +public abstract class ClearsFrontendEncoderMixin { + @Shadow + @Final + private CommandEncoderBackend backend; + + @WrapWithCondition(method = "clearColorTexture", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/api/CommandEncoderBackend;clearColorTexture(Lcom/mojang/renderpearl/api/textures/GpuTexture;Lorg/joml/Vector4fc;)V")) + private boolean vesuvio$deferColorClear(CommandEncoderBackend instance, GpuTexture texture, Vector4fc color) { + return !ClearFusion.deferColor(instance, texture, color); + } + + @WrapWithCondition(method = "clearColorAndDepthTextures", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/api/CommandEncoderBackend;clearColorAndDepthTextures(Lcom/mojang/renderpearl/api/textures/GpuTexture;Lorg/joml/Vector4fc;Lcom/mojang/renderpearl/api/textures/GpuTexture;D)V")) + private boolean vesuvio$deferColorAndDepthClear(CommandEncoderBackend instance, GpuTexture colorTexture, + Vector4fc color, GpuTexture depthTexture, double depth) { + return !ClearFusion.deferColorAndDepth(instance, colorTexture, color, depthTexture, depth); + } + + @WrapWithCondition(method = "clearDepthTexture", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/api/CommandEncoderBackend;clearDepthTexture(Lcom/mojang/renderpearl/api/textures/GpuTexture;D)V")) + private boolean vesuvio$deferDepthClear(CommandEncoderBackend instance, GpuTexture texture, double depth) { + return !ClearFusion.deferDepth(instance, texture, depth); + } + + @WrapOperation(method = "createRenderPass(Lcom/mojang/renderpearl/api/commands/RenderPassDescriptor;)Lcom/mojang/renderpearl/api/commands/RenderPass;", + at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/api/CommandEncoderBackend;createRenderPass(Lcom/mojang/renderpearl/api/commands/RenderPassDescriptor;)Lcom/mojang/renderpearl/backend/api/RenderPassBackend;")) + private RenderPassBackend vesuvio$fuseClears(CommandEncoderBackend instance, RenderPassDescriptor descriptor, + Operation original) { + return original.call(instance, ClearFusion.beforePass(instance, descriptor)); + } + + @Inject(method = "writeToTexture", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/api/CommandEncoderBackend;writeToTexture(Lcom/mojang/renderpearl/api/textures/GpuTexture;Ljava/nio/ByteBuffer;IIIIII)V")) + private void vesuvio$beforeWriteToTexture(GpuTexture destination, ByteBuffer source, int mipLevel, int depthOrLayer, + int destX, int destY, int width, int height, CallbackInfo ci) { + ClearFusion.beforeTransfer(this.backend, destination, null); + } + + @Inject(method = "copyBufferToTexture", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/api/CommandEncoderBackend;copyBufferToTexture(Lcom/mojang/renderpearl/api/buffers/GpuBufferSlice;IIIILcom/mojang/renderpearl/api/textures/GpuTexture;IIIIII)V")) + private void vesuvio$beforeCopyBufferToTexture(GpuBufferSlice source, int sourceX, int sourceY, int sourceWidth, + int sourceHeight, GpuTexture destination, int destinationX, int destinationY, int copyWidth, int copyHeight, + int mipLevel, int arrayLayer, CallbackInfo ci) { + ClearFusion.beforeTransfer(this.backend, destination, null); + } + + @Inject(method = "copyTextureToBuffer(Lcom/mojang/renderpearl/api/textures/GpuTexture;Lcom/mojang/renderpearl/api/buffers/GpuBuffer;JIIIII)V", + at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/api/CommandEncoderBackend;copyTextureToBuffer(Lcom/mojang/renderpearl/api/textures/GpuTexture;Lcom/mojang/renderpearl/api/buffers/GpuBuffer;JIIIII)V")) + private void vesuvio$beforeCopyTextureToBuffer(GpuTexture source, GpuBuffer destination, long offset, int mipLevel, + int x, int y, int width, int height, CallbackInfo ci) { + ClearFusion.beforeTransfer(this.backend, source, null); + } + + @Inject(method = "copyTextureToTexture", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/api/CommandEncoderBackend;copyTextureToTexture(Lcom/mojang/renderpearl/api/textures/GpuTexture;Lcom/mojang/renderpearl/api/textures/GpuTexture;IIIIIII)V")) + private void vesuvio$beforeCopyTextureToTexture(GpuTexture source, GpuTexture destination, int mipLevel, int destX, + int destY, int sourceX, int sourceY, int width, int height, CallbackInfo ci) { + ClearFusion.beforeTransfer(this.backend, source, destination); + } + + // The end of the frame: whatever is still pending is sent (dead candidates carry over, see ClearFusion). + @Inject(method = "submit", at = @At("HEAD")) + private void vesuvio$beforeSubmit(CallbackInfo ci) { + ClearFusion.beforeSubmit(this.backend); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsFrontendRenderPassMixin.java b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsFrontendRenderPassMixin.java new file mode 100644 index 0000000..69d1e59 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsFrontendRenderPassMixin.java @@ -0,0 +1,25 @@ +package it.cosimo.vesuvio.clears.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.renderpearl.frontend.FrontendRenderPass; +import com.mojang.renderpearl.util.TextureViewAndSampler; + +import it.cosimo.vesuvio.clears.ClearFusion; + +/** + * Setting clearFusion: every texture a pass samples is bound here (the public setUniform for textures and + * setPipeline's re-send of stored uniforms both end in this private method), before any draw can use it. + */ +@Mixin(FrontendRenderPass.class) +public abstract class ClearsFrontendRenderPassMixin { + @Inject(method = "setUniform(Ljava/lang/String;Ljava/lang/Object;)V", at = @At("HEAD")) + private void vesuvio$beforeSample(String name, Object value, CallbackInfo ci) { + if (value instanceof TextureViewAndSampler texture) { + ClearFusion.beforeSample(texture.view()); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsFrontendSurfaceMixin.java b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsFrontendSurfaceMixin.java new file mode 100644 index 0000000..06b9b6a --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsFrontendSurfaceMixin.java @@ -0,0 +1,24 @@ +package it.cosimo.vesuvio.clears.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.renderpearl.api.commands.CommandEncoder; +import com.mojang.renderpearl.api.textures.GpuTextureView; +import com.mojang.renderpearl.frontend.FrontendCommandEncoder; +import com.mojang.renderpearl.frontend.FrontendGpuSurface; + +import it.cosimo.vesuvio.clears.ClearFusion; + +/** Setting clearFusion: the copy to the window reads the texture, so its pending clear is sent first. */ +@Mixin(FrontendGpuSurface.class) +public abstract class ClearsFrontendSurfaceMixin { + @Inject(method = "blitFromTexture", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/api/GpuSurfaceBackend;blitFromTexture(Lcom/mojang/renderpearl/backend/api/CommandEncoderBackend;Lcom/mojang/renderpearl/api/textures/GpuTextureView;)V")) + private void vesuvio$beforeBlit(CommandEncoder commandEncoder, GpuTextureView textureView, CallbackInfo ci) { + // The front end has already checked that the encoder is a FrontendCommandEncoder. + ClearFusion.beforeTransfer(((FrontendCommandEncoder) commandEncoder).backend(), textureView.texture(), null); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsGameRendererMixin.java b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsGameRendererMixin.java new file mode 100644 index 0000000..fd7f5a5 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsGameRendererMixin.java @@ -0,0 +1,31 @@ +package it.cosimo.vesuvio.clears.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.renderpearl.api.commands.CommandEncoder; +import com.mojang.renderpearl.api.textures.GpuTexture; +import net.minecraft.client.renderer.GameRenderer; + +import it.cosimo.vesuvio.clears.ClearFusion; + +/** + * Setting clearFusion: the main depth clear just before the GUI (the only clearDepthTexture call in render()) + * is a dead candidate: the GUI has no depth attachment. The clear itself still goes through the encoder as + * vanilla's; ClearFusion decides when (or whether) it is recorded. + */ +@Mixin(GameRenderer.class) +public abstract class ClearsGameRendererMixin { + @WrapOperation(method = "render()V", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/api/commands/CommandEncoder;clearDepthTexture(Lcom/mojang/renderpearl/api/textures/GpuTexture;D)V")) + private void vesuvio$guiDepthClear(CommandEncoder encoder, GpuTexture texture, double depth, Operation original) { + ClearFusion.markDeadCandidate(); + try { + original.call(encoder, texture, depth); + } finally { + ClearFusion.clearDeadCandidateMark(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsGuiRendererMixin.java b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsGuiRendererMixin.java new file mode 100644 index 0000000..b75e568 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsGuiRendererMixin.java @@ -0,0 +1,27 @@ +package it.cosimo.vesuvio.clears.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.renderpearl.api.commands.CommandEncoder; +import com.mojang.renderpearl.api.textures.GpuTexture; +import net.minecraft.client.gui.render.GuiRenderer; + +import it.cosimo.vesuvio.clears.ClearFusion; + +/** Setting clearFusion: the main depth clear before the menu blur is a dead candidate too (see ClearsGameRendererMixin). */ +@Mixin(GuiRenderer.class) +public abstract class ClearsGuiRendererMixin { + @WrapOperation(method = "draw()V", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/api/commands/CommandEncoder;clearDepthTexture(Lcom/mojang/renderpearl/api/textures/GpuTexture;D)V")) + private void vesuvio$guiDepthClear(CommandEncoder encoder, GpuTexture texture, double depth, Operation original) { + ClearFusion.markDeadCandidate(); + try { + original.call(encoder, texture, depth); + } finally { + ClearFusion.clearDeadCandidateMark(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsPostChainMixin.java b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsPostChainMixin.java new file mode 100644 index 0000000..552796c --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsPostChainMixin.java @@ -0,0 +1,100 @@ +package it.cosimo.vesuvio.clears.mixin; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +import com.mojang.blaze3d.resource.RenderTargetDescriptor; +import com.mojang.blaze3d.resource.ResourceDescriptor; +import net.minecraft.client.renderer.PostChain; +import net.minecraft.client.renderer.PostPass; +import net.minecraft.resources.Identifier; + +import it.cosimo.vesuvio.clears.ClearFusion; + +/** + * Setting clearFusion, post-chain depth diet: PostChain.addToFrame gives every internal target a depth texture + * (DEFAULT_DEPTH), which the frame's pool allocates and clears every frame. Post passes draw colour only + * (PostPass opens a colour-only pass), and the only thing that reads a target's depth is an input with + * use_depth_buffer (PostPass.TargetInput.depthBuffer). A target that no input of the chain reads as depth gets + * no depth texture. A chain with an input of a kind this does not know (a mod's) keeps vanilla's depth for all + * its targets. Etna's own chains already have none (its PostChainMixin); the two hooks chain without clashing. + */ +@Mixin(PostChain.class) +public abstract class ClearsPostChainMixin { + @Shadow + @Final + private List passes; + + /** Targets some input reads as depth, by name and by id; null until first needed (passes never change). */ + @Unique + private Set vesuvio$depthRead; + + @Unique + private Set vesuvio$depthReadNames; + + /** An input of an unknown kind: keep every depth texture of this chain. */ + @Unique + private boolean vesuvio$keepAllDepth; + + @ModifyArg(method = "addToFrame", at = @At(value = "INVOKE", + target = "Lcom/mojang/blaze3d/framegraph/FrameGraphBuilder;createInternal(Ljava/lang/String;Lcom/mojang/blaze3d/resource/ResourceDescriptor;)Lcom/mojang/blaze3d/resource/ResourceHandle;"), + index = 1) + private ResourceDescriptor vesuvio$noUnusedDepth(String name, ResourceDescriptor descriptor) { + if (!ClearFusion.CLEAR_FUSION.on() || !(descriptor instanceof RenderTargetDescriptor target) || target.depth() == null + || target.color() == null) { + return descriptor; + } + this.vesuvio$scanInputs(); + if (this.vesuvio$keepAllDepth || this.vesuvio$depthReadNames.contains(name)) { + return descriptor; + } + return new RenderTargetDescriptor(target.width(), target.height(), target.color(), null); + } + + @ModifyArg(method = "addToFrame", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/PostChain;getOrCreatePersistentTarget(Lnet/minecraft/resources/Identifier;Lcom/mojang/blaze3d/resource/RenderTargetDescriptor;)Lcom/mojang/blaze3d/pipeline/RenderTarget;"), + index = 1) + private RenderTargetDescriptor vesuvio$noUnusedPersistentDepth(Identifier id, RenderTargetDescriptor target) { + if (!ClearFusion.CLEAR_FUSION.on() || target.depth() == null || target.color() == null) { + return target; + } + this.vesuvio$scanInputs(); + if (this.vesuvio$keepAllDepth || this.vesuvio$depthRead.contains(id)) { + return target; + } + return new RenderTargetDescriptor(target.width(), target.height(), target.color(), null); + } + + @Unique + private void vesuvio$scanInputs() { + if (this.vesuvio$depthRead != null) { + return; + } + Set ids = new HashSet<>(); + Set names = new HashSet<>(); + boolean keepAll = false; + for (PostPass pass : this.passes) { + for (PostPass.Input input : ((ClearsPostPassAccessor) pass).vesuvio$inputs()) { + if (input instanceof PostPass.TargetInput targetInput) { + if (targetInput.depthBuffer()) { + ids.add(targetInput.targetId()); + names.add(targetInput.targetId().toString()); + } + } else if (!(input instanceof PostPass.TextureInput)) { + keepAll = true; + } + } + } + this.vesuvio$keepAllDepth = keepAll; + this.vesuvio$depthReadNames = names; + this.vesuvio$depthRead = ids; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsPostPassAccessor.java b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsPostPassAccessor.java new file mode 100644 index 0000000..bd0bf89 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsPostPassAccessor.java @@ -0,0 +1,15 @@ +package it.cosimo.vesuvio.clears.mixin; + +import java.util.List; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import net.minecraft.client.renderer.PostPass; + +/** Setting clearFusion: a post pass's inputs, to see which targets are read as depth. */ +@Mixin(PostPass.class) +public interface ClearsPostPassAccessor { + @Accessor("inputs") + List vesuvio$inputs(); +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsVulkanEncoderAccessor.java b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsVulkanEncoderAccessor.java new file mode 100644 index 0000000..74c7614 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsVulkanEncoderAccessor.java @@ -0,0 +1,31 @@ +package it.cosimo.vesuvio.clears.mixin; + +import org.joml.Vector4fc; +import org.jspecify.annotations.Nullable; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.vulkan.VkCommandBuffer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +import com.mojang.renderpearl.api.textures.GpuTexture; +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; +import com.mojang.renderpearl.backend.vulkan.VulkanDevice; +import com.mojang.renderpearl.backend.vulkan.VulkanRenderPass; + +/** Setting clearFusion: what a clear recorded in the middle of a pass needs from the Vulkan encoder. */ +@Mixin(VulkanCommandEncoder.class) +public interface ClearsVulkanEncoderAccessor { + @Invoker("commandBuffer") + VkCommandBuffer vesuvio$commandBuffer(); + + @Invoker("clearColorTextureUnsynced") + void vesuvio$clearColorTextureUnsynced(MemoryStack stack, GpuTexture colorTexture, Vector4fc clearColor); + + @Accessor("device") + VulkanDevice vesuvio$device(); + + @Accessor("currentRenderPass") + @Nullable + VulkanRenderPass vesuvio$currentRenderPass(); +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsVulkanEncoderMixin.java b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsVulkanEncoderMixin.java new file mode 100644 index 0000000..e1646ff --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsVulkanEncoderMixin.java @@ -0,0 +1,31 @@ +package it.cosimo.vesuvio.clears.mixin; + +import org.lwjgl.vulkan.VkCommandBuffer; +import org.lwjgl.vulkan.VkRenderPassBeginInfo; +import org.lwjgl.vulkan.VkSubpassBeginInfo; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.sugar.Local; +import com.mojang.renderpearl.api.commands.RenderPassDescriptor; +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; + +import it.cosimo.vesuvio.clears.ClearFusion; + +/** + * Setting clearFusion: remembers the framebuffer and render area of the pass being begun, so a clear that has + * to be recorded in the middle of it can begin the same pass again (ClearFusion.splitPass). Only reads. + */ +@Mixin(VulkanCommandEncoder.class) +public abstract class ClearsVulkanEncoderMixin { + @WrapOperation(method = "createRenderPass", at = @At(value = "INVOKE", + target = "Lorg/lwjgl/vulkan/VK12;vkCmdBeginRenderPass2(Lorg/lwjgl/vulkan/VkCommandBuffer;Lorg/lwjgl/vulkan/VkRenderPassBeginInfo;Lorg/lwjgl/vulkan/VkSubpassBeginInfo;)V")) + private void vesuvio$rememberPassBegin(VkCommandBuffer commandBuffer, VkRenderPassBeginInfo beginInfo, + VkSubpassBeginInfo subpassBeginInfo, Operation original, + @Local(argsOnly = true) RenderPassDescriptor descriptor) { + ClearFusion.onVulkanPassBegin(descriptor, beginInfo); + original.call(commandBuffer, beginInfo, subpassBeginInfo); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsVulkanTextureMixin.java b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsVulkanTextureMixin.java new file mode 100644 index 0000000..12adea2 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/clears/mixin/ClearsVulkanTextureMixin.java @@ -0,0 +1,24 @@ +package it.cosimo.vesuvio.clears.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.renderpearl.api.textures.GpuTexture; +import com.mojang.renderpearl.backend.vulkan.VulkanGpuTexture; + +import it.cosimo.vesuvio.clears.ClearFusion; + +/** + * Setting clearFusion: the image is queued for destruction once the texture and its last view are closed. From + * then on nothing can read it, so a clear still pending for it is dropped (never recorded on a freed image). + */ +@Mixin(VulkanGpuTexture.class) +public abstract class ClearsVulkanTextureMixin { + @Inject(method = "removeViews", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanCommandEncoder;queueForDestroy(Lcom/mojang/renderpearl/backend/vulkan/Destroyable;)V")) + private void vesuvio$dropPendingClear(CallbackInfo ci) { + ClearFusion.onTextureDestroyed((GpuTexture) (Object) this); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/CullingBoost.java b/src/main/java/it/cosimo/vesuvio/culling/CullingBoost.java new file mode 100644 index 0000000..0bc35bc --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/CullingBoost.java @@ -0,0 +1,16 @@ +package it.cosimo.vesuvio.culling; + +/** + * Culling of what can't be seen for other reasons than walls: fog (Rendering page) and small details such as sign + * text, item frames and beacon beams (Entities page). The wall-based culling of entities is in the entities package. + */ +public final class CullingBoost { + private CullingBoost() { + } + + /** Creates the settings, in display order, and registers the counters. */ + public static void init() { + FogCulling.init(); + DetailCulling.init(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/DetailCulling.java b/src/main/java/it/cosimo/vesuvio/culling/DetailCulling.java new file mode 100644 index 0000000..919f9ef --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/DetailCulling.java @@ -0,0 +1,391 @@ +package it.cosimo.vesuvio.culling; + +import java.util.List; + +import org.joml.Matrix4f; +import org.joml.Vector3fc; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.ui.VesuvioStats; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.blockentity.BeaconRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher; +import net.minecraft.client.renderer.blockentity.state.SignRenderState; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.state.level.PlayerRenderState; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.decoration.ItemFrame; +import net.minecraft.world.level.block.entity.BeaconBeamOwner; +import net.minecraft.world.level.block.entity.BeaconBlockEntity; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.phys.Vec3; + +/** + * Small culling bundle (ideas from More Culling by FX - PR0CESS, GPL; Vesuvio's own code). Each part skips only + * work whose result covers no pixel: + *

    + *
  • Sign text. The text of the side facing away from the camera: text pipelines cull back faces, so the GPU + * would discard every glyph anyway (and the board stands in front of it). And all text once a whole glyph is + * smaller than one pixel of the actual window at the current field of view (see {@link #subPixel}).
  • + *
  • Item frames. The whole frame, item or map when the camera is behind the wall it hangs on: every line of + * sight to the frame goes through the wall's opaque full blocks (checked block by block), whose faces are drawn + * towards the camera. And the framed item or map alone once it is smaller than one pixel.
  • + *
  • Beacon beams. Vanilla draws every beacon's beam every frame, wherever it is (a "globally rendered" block + * entity, never frustum culled). A beam whose whole cylinder lies outside the view cone, with room for view bobbing + * and the hurt tilt, is skipped.
  • + *
+ */ +public final class DetailCulling { + public static final VesuvioConfig.Toggle SIGNS = VesuvioConfig.toggle("entities", "signTextCulling", true, false); + public static final VesuvioConfig.Toggle ITEM_FRAMES = VesuvioConfig.toggle("entities", "itemFrameCulling", true, false); + public static final VesuvioConfig.Toggle BEACONS = VesuvioConfig.toggle("entities", "beaconBeamCulling", true, false); + + // ---- Counters (render thread) ---- + /** Sign sides whose text was skipped last frame. */ + public static int signTextsCulledLastFrame; + /** Item frames skipped whole (behind their wall) or their item/map skipped (sub-pixel) last frame. */ + public static int itemFramesCulledLastFrame; + /** Beacon beams skipped last frame. */ + public static int beaconBeamsCulledLastFrame; + + /** The camera must be this far behind a text's plane (blocks; glyph effects sit a hair off it). */ + private static final double SIGN_BACK_MARGIN = 0.02; + /** Glyphs can be wider than the line is high (wide Unicode glyphs, some fonts): this much margin on the size. */ + private static final double GLYPH_SIZE_FACTOR = 1.5; + /** Item frame: the box its frame, item and map can occupy, from the wall face (generous for oversized items). */ + private static final double FRAME_HALF_WIDTH = 0.75; + private static final double FRAME_DEPTH = 1.0; + /** The camera must be at least this far behind the wall face (camera near plane plus margin). */ + private static final double FRAME_CAMERA_BEHIND = 0.25; + /** Most wall blocks checked for one frame. */ + private static final int FRAME_MAX_CELLS = 25; + /** Framed item or map: largest size on screen (a map is a block, a 3D item a bit more) and reach from its pose. */ + private static final double FRAME_ITEM_SIZE = 1.5; + private static final double FRAME_ITEM_REACH = 1.0; + /** Beam view cone margin: hurt tilt (up to 14 degrees) plus view bobbing (up to 5). */ + private static final double BEAM_MARGIN_DEGREES = 20.0; + /** The eye moves up to about a block with view bobbing. */ + private static final double BEAM_EYE_REACH = 1.0; + /** Sub-pixel tests: how far view bobbing can move the eye (bob is at most 0.1, the shift half a bob or a bob). */ + private static final double EYE_SHIFT = 0.5; + + private static final BlockPos.MutableBlockPos SCRATCH = new BlockPos.MutableBlockPos(); + + private static int signCount; + private static int frameCount; + private static int beamCount; + private static int submitFrameCount; + + private DetailCulling() { + } + + /** Called from CullingBoost.init: makes the settings and registers the counters. */ + public static void init() { + VesuvioStats.register("sign texts culled", () -> signTextsCulledLastFrame); + VesuvioStats.register("item frames culled", () -> itemFramesCulledLastFrame); + VesuvioStats.register("beacon beams culled", () -> beaconBeamsCulledLastFrame); + } + + /** LevelExtractor.extract, head. */ + public static void beginExtract() { + frameCount = 0; + beamCount = 0; + } + + /** LevelExtractor.extract, return. */ + public static void endExtract() { + beaconBeamsCulledLastFrame = beamCount; + itemFramesCulledLastFrame = frameCount + submitFrameCount; + } + + /** LevelRenderer.submitFeatures, head (block entities and entities are submitted inside it). */ + public static void beginSubmit() { + signCount = 0; + submitFrameCount = 0; + } + + /** LevelRenderer.submitFeatures, return. */ + public static void endSubmit() { + signTextsCulledLastFrame = signCount; + } + + // ------------------------------------------------------------------ screen size + + /** + * True if something no bigger than {@code size} blocks, whose every point is at least {@code distance} blocks from + * the eye, is smaller than one pixel of the window at this frame's field of view, wherever it is on the screen. + *
    + *
  • Seen from at least {@code distance}, a thing of diameter {@code size} spans an angle of at most + * 2 asin(size / (2 distance)).
  • + *
  • The projection turns angles into screen distances by at most (height / 2) m11 / cos^2(t) pixels per radian at + * the angle t from the view axis (the stretch towards the edges, largest along the radius), and every point that + * reaches the window lies within the half-diagonal angle of the screen, so that angle bounds t. At 70 degrees and + * 16:9 this is three times the centre's scale.
  • + *
  • View bobbing and the hurt tilt rotate the scene in front of the same projection (no stretch) and move the eye + * by a fraction of a block ({@link #EYE_SHIFT}). The nausea and portal effects stretch the picture: no culling + * while they show.
  • + *
+ */ + static boolean subPixel(double size, double distance, CameraRenderState camera) { + distance -= EYE_SHIFT; + if (!(distance > 1.0) || camera.isPanoramicMode) { + return false; + } + Minecraft mc = Minecraft.getInstance(); + PlayerRenderState player = mc.gameRenderer.gameRenderState().levelRenderState.playerRenderState; + if (player.portalEffectIntensity > 0.0F || player.nauseaEffectIntensity > 0.0F) { + return false; + } + int height = mc.gameRenderer.mainRenderTarget().height; + double m00 = camera.projectionMatrix.m00(); + double m11 = camera.projectionMatrix.m11(); + if (!(height > 0) || !(m00 > 0.0) || !(m11 > 0.0)) { + return false; + } + double tanX = 1.0 / m00; + double tanY = 1.0 / m11; + // 1 / cos^2 of the half-diagonal angle. + double edgeStretch = 1.0 + tanX * tanX + tanY * tanY; + double angle = 2.0 * Math.asin(Math.min(1.0, size / (2.0 * distance))); + return 0.5 * height * m11 * edgeStretch * angle < 1.0; + } + + // ------------------------------------------------------------------ signs + + /** + * AbstractSignRenderer.submit, for one side's text about to be submitted with {@code poseStack} (camera-relative, + * the side's text transformation applied): true to skip it. + */ + public static boolean signTextHidden(SignRenderState state, Matrix4f pose, CameraRenderState camera) { + if (!SIGNS.on()) { + return false; + } + // Text plane: the pose's x and y axes through its origin; the camera is at the origin. The text reads the + // right way round from the side opposite to (x axis) cross (y axis), whatever the transformation's handedness. + double px = pose.m30(); + double py = pose.m31(); + double pz = pose.m32(); + double ax = pose.m00(); + double ay = pose.m01(); + double az = pose.m02(); + double bx = pose.m10(); + double by = pose.m11(); + double bz = pose.m12(); + double nx = ay * bz - az * by; + double ny = az * bx - ax * bz; + double nz = ax * by - ay * bx; + double nLength = Math.sqrt(nx * nx + ny * ny + nz * nz); + if (!(nLength > 0.0)) { + return false; + } + // (camera - origin) . n = -(p . n): positive means the camera is on the back side. + if (-(px * nx + py * ny + pz * nz) > SIGN_BACK_MARGIN * nLength) { + signCount++; + return true; + } + double scale = Math.sqrt(bx * bx + by * by + bz * bz); + double lineHeight = Math.max(state.textLineHeight, 9); + double halfWidth = Math.max(state.maxTextLineWidth, 90) * 0.5; + double reach = Math.sqrt(halfWidth * halfWidth + 4.0 * lineHeight * lineHeight) * scale; + double distance = Math.sqrt(px * px + py * py + pz * pz) - reach; + if (subPixel(lineHeight * scale * GLYPH_SIZE_FACTOR, distance, camera)) { + signCount++; + return true; + } + return false; + } + + // ------------------------------------------------------------------ item frames + + /** + * LevelExtractor.extractVisibleEntities, for an item frame vanilla is about to extract: true when the camera is + * behind the wall it hangs on and every line of sight to it crosses the wall through opaque full blocks. + */ + public static boolean itemFrameHidden(ItemFrame frame, double camX, double camY, double camZ, ClientLevel level) { + if (!ITEM_FRAMES.on() || level == null) { + return false; + } + Minecraft mc = Minecraft.getInstance(); + if (frame == mc.crosshairPickEntity || mc.shouldEntityAppearGlowing(frame) + || frame.shouldShowName() && !frame.isDiscrete()) { + return false; + } + Direction facing = frame.getDirection(); + BlockPos pos = frame.getPos(); + // Axes: n (out of the wall), u and v (along it). Coordinates relative to the centre of the wall face. + Direction.Axis axis = facing.getAxis(); + int step = facing.getAxisDirection().getStep(); + double faceX = pos.getX() + 0.5 - facing.getStepX() * 0.5; + double faceY = pos.getY() + 0.5 - facing.getStepY() * 0.5; + double faceZ = pos.getZ() + 0.5 - facing.getStepZ() * 0.5; + double rx = camX - faceX; + double ry = camY - faceY; + double rz = camZ - faceZ; + double cn = step * pick(axis, rx, ry, rz); + if (!(cn < -FRAME_CAMERA_BEHIND)) { + return false; + } + double cu = pickU(axis, rx, ry, rz); + double cv = pickV(axis, rx, ry, rz); + // The camera must not be inside an opaque block itself (spectators in walls see through them). + SCRATCH.set(Mth.floor(camX), Mth.floor(camY), Mth.floor(camZ)); + if (level.getBlockState(SCRATCH).isSolidRender()) { + return false; + } + // Project the frame's box corners onto the wall plane from the camera: the lines of sight cross the plane + // inside the convex hull of those points, so inside their bounding rectangle. + double minU = Double.POSITIVE_INFINITY; + double maxU = Double.NEGATIVE_INFINITY; + double minV = Double.POSITIVE_INFINITY; + double maxV = Double.NEGATIVE_INFINITY; + for (int i = 0; i < 8; i++) { + double pu = (i & 1) == 0 ? -FRAME_HALF_WIDTH : FRAME_HALF_WIDTH; + double pv = (i & 2) == 0 ? -FRAME_HALF_WIDTH : FRAME_HALF_WIDTH; + double pn = (i & 4) == 0 ? 0.0 : FRAME_DEPTH; + double t = pn / (pn - cn); + double qu = pu + (cu - pu) * t; + double qv = pv + (cv - pv) * t; + minU = Math.min(minU, qu); + maxU = Math.max(maxU, qu); + minV = Math.min(minV, qv); + maxV = Math.max(maxV, qv); + } + // Wall cells (the layer behind the face; cell 0 is the frame's support block) covering the rectangle. + int iMin = Mth.floor(minU + 0.5); + int iMax = Mth.floor(maxU + 0.5); + int jMin = Mth.floor(minV + 0.5); + int jMax = Mth.floor(maxV + 0.5); + if ((long) (iMax - iMin + 1) * (jMax - jMin + 1) > FRAME_MAX_CELLS) { + return false; + } + BlockPos support = pos.relative(facing.getOpposite()); + for (int i = iMin; i <= iMax; i++) { + for (int j = jMin; j <= jMax; j++) { + setCell(support, axis, i, j); + if (!level.getBlockState(SCRATCH).isSolidRender()) { + return false; + } + } + } + frameCount++; + return true; + } + + private static double pick(Direction.Axis axis, double x, double y, double z) { + return axis == Direction.Axis.X ? x : axis == Direction.Axis.Y ? y : z; + } + + /** First axis along the wall: Z for an X wall, X otherwise. */ + private static double pickU(Direction.Axis axis, double x, double y, double z) { + return axis == Direction.Axis.X ? z : x; + } + + /** Second axis along the wall: Z for a floor or ceiling, Y otherwise. */ + private static double pickV(Direction.Axis axis, double x, double y, double z) { + return axis == Direction.Axis.Y ? z : y; + } + + private static void setCell(BlockPos support, Direction.Axis axis, int u, int v) { + switch (axis) { + case X -> SCRATCH.set(support.getX(), support.getY() + v, support.getZ() + u); + case Y -> SCRATCH.set(support.getX() + u, support.getY(), support.getZ() + v); + default -> SCRATCH.set(support.getX() + u, support.getY() + v, support.getZ()); + } + } + + /** + * ItemFrameRenderer.submit, for the framed item or map about to be submitted with {@code pose} (camera-relative): + * true to skip it because it is smaller than a pixel. + */ + public static boolean framedItemHidden(Matrix4f pose, int outlineColor, CameraRenderState camera) { + // A glowing frame's outline is drawn a few pixels wide around whatever pixel the item covers. + if (!ITEM_FRAMES.on() || outlineColor != 0) { + return false; + } + double px = pose.m30(); + double py = pose.m31(); + double pz = pose.m32(); + double distance = Math.sqrt(px * px + py * py + pz * pz) - FRAME_ITEM_REACH; + if (subPixel(FRAME_ITEM_SIZE, distance, camera)) { + submitFrameCount++; + return true; + } + return false; + } + + // ------------------------------------------------------------------ beacon beams + + /** + * LevelExtractor.extractVisibleBlockEntities, globally rendered list: true to skip a vanilla beacon whose beam is + * wholly outside the view. + */ + public static boolean beaconHidden(BlockEntity blockEntity, BlockEntityRenderDispatcher dispatcher, + CameraRenderState cameraState, PlayerRenderState player, Vector3fc forward) { + if (!BEACONS.on() || !(blockEntity instanceof BeaconBlockEntity) + || dispatcher.getRenderer(blockEntity) == null + || dispatcher.getRenderer(blockEntity).getClass() != BeaconRenderer.class) { + return false; + } + // The nausea and portal effects warp the projection; the panorama camera has its own. + if (cameraState.isPanoramicMode || player.portalEffectIntensity > 0.0F || player.nauseaEffectIntensity > 0.0F) { + return false; + } + List sections = ((BeaconBeamOwner) blockEntity).getBeamSections(); + BlockPos pos = blockEntity.getBlockPos(); + Vec3 cam = cameraState.pos; + double dx = pos.getX() + 0.5 - cam.x; + double dz = pos.getZ() + 0.5 - cam.z; + double h = Math.sqrt(dx * dx + dz * dz); + // BeaconRenderer: beam radius 0.25, times distance / 96 when far (larger than with a spyglass). + double radius = 0.25 * Math.max(1.0, h / 96.0) + BEAM_EYE_REACH; + if (!(h > radius + 1.0)) { + return false; + } + // Height: every section but the last at its own height, the last to 2048 above its start. + int top = 0; + for (int i = 0; i < sections.size() - 1; i++) { + top += Math.max(0, sections.get(i).getHeight()); + } + top += 2048; + double s0 = pos.getY() - cam.y - BEAM_EYE_REACH; + double s1 = pos.getY() + top - cam.y + BEAM_EYE_REACH; + + // Smallest angle between the view direction and the beam's axis: maximise + // cos(s) = (k + fy s) / sqrt(h^2 + s^2) over the axis heights s, k = forward . (dx, 0, dz). + double fx = forward.x(); + double fy = forward.y(); + double fz = forward.z(); + double k = fx * dx + fz * dz; + double best = Math.max(cosToAxis(k, fy, h, s0), cosToAxis(k, fy, h, s1)); + if (k > 0.0) { + double critical = fy * h * h / k; + if (critical > s0 && critical < s1) { + best = Math.max(best, cosToAxis(k, fy, h, critical)); + } + } + // View cone: the corner of the screen (half-diagonal angle), plus the margin, plus the beam's own width. + double tanY = 1.0 / cameraState.projectionMatrix.m11(); + double tanX = 1.0 / cameraState.projectionMatrix.m00(); + if (!(tanX > 0.0) || !(tanY > 0.0)) { + return false; + } + double halfDiagonal = Math.toDegrees(Math.atan(Math.sqrt(tanX * tanX + tanY * tanY))); + double width = Math.toDegrees(Math.asin(Math.min(1.0, radius / h))); + double limit = halfDiagonal + BEAM_MARGIN_DEGREES + width; + if (limit >= 180.0) { + return false; + } + if (best < Math.cos(Math.toRadians(limit))) { + beamCount++; + return true; + } + return false; + } + + private static double cosToAxis(double k, double fy, double h, double s) { + return (k + fy * s) / Math.sqrt(h * h + s * s); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/FogCulling.java b/src/main/java/it/cosimo/vesuvio/culling/FogCulling.java new file mode 100644 index 0000000..72a42ac --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/FogCulling.java @@ -0,0 +1,478 @@ +package it.cosimo.vesuvio.culling; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; + +import it.cosimo.vesuvio.Vesuvio; +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.culling.mixin.CullEntityRendererInvoker; +import it.cosimo.vesuvio.culling.mixin.FogRendererAccessor; +import it.cosimo.vesuvio.culling.mixin.ParticleAccessor; +import it.cosimo.vesuvio.ui.VesuvioStats; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.CloudStatus; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.particle.SingleQuadParticle; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.client.renderer.entity.EntityRenderDispatcher; +import net.minecraft.client.renderer.fog.FogData; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.state.level.LevelRenderState; +import net.minecraft.client.renderer.state.level.SkyRenderState; +import net.minecraft.core.BlockPos; +import net.minecraft.util.ARGB; +import net.minecraft.util.Mth; +import net.minecraft.world.attribute.EnvironmentAttributes; +import net.minecraft.world.entity.Display; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LightningBolt; +import net.minecraft.world.entity.Leashable; +import net.minecraft.world.entity.boss.enderdragon.EndCrystal; +import net.minecraft.world.entity.boss.enderdragon.EnderDragon; +import net.minecraft.world.entity.boss.enderdragon.EnderDragonPart; +import net.minecraft.world.entity.boss.wither.WitherBoss; +import net.minecraft.world.entity.monster.Creeper; +import net.minecraft.world.entity.monster.Guardian; +import net.minecraft.world.entity.projectile.FishingHook; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.material.FogType; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * Fog culling: terrain sections, entities, block entities and particles that the fog already paints over completely + * are not drawn at all (an idea Sodium made known; Vesuvio's own code). Biggest under water, in lava or powder snow, + * with Blindness or Darkness, in the Nether and in a boss's world fog; nothing in clear overworld air, where the fog + * ends past the render distance. + * + *

Why the picture stays the same. Every world shader mixes its colour towards the fog colour by + * {@code linear_fog_value(distance, FogEnvironmentalStart, FogEnvironmentalEnd)}, which is exactly 1 from the fog + * end on (fog.glsl); terrain then writes exactly {@code FogColor}, entities, particles, text and lines write + * {@code mix(colour, FogColor, 1 * FogColor.a)} with an alpha of 1, glints and lightning fade to nothing. The distance + * is per vertex, interpolated, and the distance to a box's nearest point is a lower bound for all of them, so a box + * whose nearest point is past the fog end only ever produces fog-coloured pixels. Taking it away shows what is behind + * it, which must be fog-coloured too: + *

    + *
  • anything farther away that the world shaders draw is past the fog end as well (fog colour, blended or not);
  • + *
  • the background: the main pass clears to the same fog colour. Where vanilla draws a sky (open air, water, the + * End) only the directions below the sky occluder's fully opaque edge are fog colour (under water: 20 degrees above + * the horizon and lower), so there a box must lie wholly below it (with a margin for view bobbing); where no sky is + * drawn (lava, powder snow, Blindness, Darkness, boss fog, the Nether) the whole background is fog colour;
  • + *
  • clouds farther away are transparent once past their own fog end, so with clouds drawn the cut-off is the larger + * of the two;
  • + *
  • things drawn without fog would show through: the world border (no culling while it is drawn), debug overlays + * (no culling while there are any), glowing outlines and see-through name tags (those entities are never culled, and + * both are drawn on top anyway).
  • + *
+ * Off with Improved Transparency (its fog blends into a sky texture, and its order-independent blending is not a plain + * "fog colour over fog colour"), with the F3 fog toggle off, when the fog colour is not opaque, and while Etna Shaders + * draws the world (its terrain fog is not vanilla's linear one). Terrain is cut in vanilla's main-view call only: + * other prepares (a shader mod's shadow pass) see every section. The fog values are this frame's, the ones uploaded + * to the shaders. + */ +public final class FogCulling { + public static final VesuvioConfig.Toggle SETTING = VesuvioConfig.toggle("rendering", "fogCulling", true, false); + + // ---- Counters (render thread writes, settings screen / F3 / benchmark read) ---- + /** Terrain sections left out of the main view last frame. */ + public static int sectionsCulledLastFrame; + /** Entities and block entities not extracted last frame. */ + public static int objectsCulledLastFrame; + /** Particles not extracted last frame. */ + public static int particlesCulledLastFrame; + /** Fog end the culling used last frame, in blocks (0 = not culling). */ + public static int distanceLastFrame; + + /** How far a section's mesh can reach outside its 16x16x16 box (model elements reach one block out). */ + private static final double SECTION_REACH = 1.0; + /** Added to the fog end: float rounding in the shaders, nothing visible. */ + private static final double DISTANCE_MARGIN = 0.5; + /** View bobbing moves the eye by up to about a block, which tilts lines of sight a few degrees. */ + private static final double ELEVATION_MARGIN_DEGREES = 4.0; + /** Room around an entity's culling box: held items, capes, flames, a non-see-through name tag. */ + private static final double ENTITY_REACH = 2.0; + /** Never cull an entity box bigger than this on any axis (huge custom models, broken boxes). */ + private static final double MAX_ENTITY_BOX = 32.0; + /** Beyond this the fog is not worth testing against (vanilla's clear-air fog end is 1024). */ + private static final double MAX_USEFUL_DISTANCE = 1000.0; + + private static final ClassValue VANILLA_CLASS = new ClassValue<>() { + @Override + protected Boolean computeValue(Class type) { + return type.getName().startsWith("net.minecraft."); + } + }; + + /** One view's culling state: the fog cut-off and which directions have a fog-coloured background. */ + private static final class View { + boolean active; + double camX; + double camY; + double camZ; + /** Squared distance from the camera beyond which a box is fully fogged. */ + double cutoffSq; + /** Highest tangent of elevation whose background is fog colour (+infinity: every direction). */ + double maxTan; + } + + private static final View EXTRACT = new View(); + private static final View MAIN = new View(); + private static boolean inMainView; + /** The main view's last frame had standard debug gizmos (lines and boxes drawn without fog). */ + private static boolean gizmosLastFrame; + private static boolean gizmosThisFrame; + + private static int sectionCount; + private static int objectCount; + private static int particleCount; + + private FogCulling() { + } + + /** Called from CullingBoost.init: makes the setting and registers the counters. */ + public static void init() { + VesuvioStats.register("fog culled sections", () -> sectionsCulledLastFrame); + VesuvioStats.register("fog culled entities", () -> objectsCulledLastFrame); + VesuvioStats.register("fog culled particles", () -> particlesCulledLastFrame); + } + + // ------------------------------------------------------------------ setup + + /** + * Works out the cut-off for one view. {@code skybox}, {@code occluder*} describe the sky vanilla will draw this + * frame; {@code cloudsDrawn} whether it draws clouds; {@code borderDrawn} the world border. + */ + private static void setup(View view, CameraRenderState camera, boolean improvedTransparency, boolean cloudsDrawn, + DimensionType.Skybox skybox, boolean hasOccluder, float occluderStart, float occluderEnd, + boolean borderDrawn, boolean gizmos) { + view.active = false; + if (!SETTING.on() || camera == null || !camera.initialized || improvedTransparency || borderDrawn || gizmos + || !FogRendererAccessor.vesuvio$fogEnabled() || Etna.active()) { + return; + } + FogData fog = camera.fogData; + if (fog == null || fog.color == null || fog.color.w != 1.0F) { + return; + } + double cutoff = Math.max(fog.environmentalStart, fog.environmentalEnd); + if (cloudsDrawn) { + cutoff = Math.max(cutoff, fog.cloudEnd); + } + // NaN fails this too. + if (!(cutoff < MAX_USEFUL_DISTANCE)) { + return; + } + cutoff = Math.max(0.0, cutoff) + DISTANCE_MARGIN; + + // LevelRenderer.shouldRenderSky, then SkyRenderer.renderSkyOccluder: smoothstep(start, end, elevation) is + // exactly 1 (fog colour, opaque, drawn over the sun, moon and stars) at or below "end" when start > end. + FogType fogType = camera.fogType; + boolean skyDrawn = !fog.shouldCreateBossFog && fogType != FogType.POWDER_SNOW && fogType != FogType.LAVA + && !camera.entityRenderState.doesMobEffectBlockSky && skybox != DimensionType.Skybox.NONE; + double maxTan; + if (!skyDrawn) { + maxTan = Double.POSITIVE_INFINITY; + } else if (skybox == DimensionType.Skybox.OVERWORLD && hasOccluder && occluderStart > occluderEnd) { + maxTan = Math.tan(Math.toRadians(occluderEnd - ELEVATION_MARGIN_DEGREES)); + } else { + return; + } + Vec3 pos = camera.pos; + view.camX = pos.x; + view.camY = pos.y; + view.camZ = pos.z; + view.cutoffSq = cutoff * cutoff; + view.maxTan = maxTan; + view.active = true; + } + + /** + * LevelExtractor.extract, head: entities, block entities and particles. The camera, its fog and its effects are + * already this frame's (GameRenderer.extractCamera runs first); the sky and the border are extracted later in the + * same call, so they are worked out here the way SkyRenderer and WorldBorderRenderer will. + */ + public static void beginExtract(Minecraft minecraft, ClientLevel level, LevelRenderState state, float partialTicks) { + objectCount = 0; + particleCount = 0; + EXTRACT.active = false; + if (!SETTING.on() || level == null || state == null) { + return; + } + try { + CameraRenderState camera = state.cameraRenderState; + int renderDistance = minecraft.options.getEffectiveRenderDistance() * 16; + double toBorder = level.getWorldBorder().getDistanceToBorder(camera.pos.x, camera.pos.z); + boolean borderDrawn = Math.abs(toBorder) < renderDistance + 16; + DimensionType.Skybox skybox = level.dimensionType().skybox(); + boolean hasOccluder = false; + float start = 0.0F; + float end = 0.0F; + if (skybox == DimensionType.Skybox.OVERWORLD) { + hasOccluder = minecraft.gameRenderer.mainCamera().attributeProbe() + .getValue(EnvironmentAttributes.HAS_SKY_OCCLUDER, partialTicks); + LocalPlayer player = minecraft.player; + if (player != null && player.getEyePosition(partialTicks).y < level.getMinY()) { + start = 15.0F; + end = 10.0F; + } else if (camera.fogType == FogType.WATER) { + start = 40.0F; + end = 20.0F; + } else { + start = -1.0F; + end = -8.0F; + } + } + int cloudColor = ARGB.colorFromVector4f(minecraft.gameRenderer.mainCamera().attributeProbe() + .getValue(EnvironmentAttributes.CLOUD_COLOR, partialTicks)); + boolean clouds = minecraft.options.getCloudStatus() != CloudStatus.OFF && ARGB.alpha(cloudColor) > 0; + setup(EXTRACT, camera, minecraft.gameRenderer.useImprovedTransparency() || minecraft.wireframe, clouds, + skybox, hasOccluder, start, end, borderDrawn, gizmosLastFrame); + } catch (RuntimeException e) { + EXTRACT.active = false; + Vesuvio.LOGGER.debug("Vesuvio: fog culling off this frame", e); + } + } + + /** LevelExtractor.extract, return. */ + public static void endExtract() { + objectsCulledLastFrame = objectCount; + particlesCulledLastFrame = particleCount; + EXTRACT.active = false; + } + + /** LevelRenderer.submitFeatures: whether this frame draws standard (depth-tested, unfogged) debug gizmos. */ + public static void noteGizmos(boolean present) { + gizmosThisFrame = present; + } + + /** Around vanilla's main-view prepareChunkRenders(Indirect) call in LevelRenderer.render. */ + public static void beginMainView(LevelRenderState state, boolean improvedTransparency, CloudStatus cloudStatus) { + sectionCount = 0; + inMainView = true; + gizmosLastFrame = gizmosThisFrame; + try { + SkyRenderState sky = state.skyRenderState; + boolean clouds = cloudStatus != CloudStatus.OFF && ARGB.alpha(state.cloudColor) > 0; + setup(MAIN, state.cameraRenderState, improvedTransparency || state.renderWireframeTerrain, clouds, + sky.skybox, sky.hasSkyOccluder, sky.occluderStartAngle, sky.occluderEndAngle, + state.worldBorderRenderState.alpha > 0.0, gizmosThisFrame); + if (MAIN.active && !withinViewArea(MAIN)) { + // Rain, for one: a cut-off past every section vanilla can draw cuts nothing, and would only keep + // SectionDrawCache from reusing the lists (see mainViewCutting). + MAIN.active = false; + } + } catch (RuntimeException e) { + MAIN.active = false; + } + distanceLastFrame = MAIN.active ? (int) Math.sqrt(MAIN.cutoffSq) : 0; + } + + /** + * Whether a section of the view area (render distance chunks around the camera, the level's full height) can lie + * past the cut-off. Too large a bound only keeps the cut on; too small one only draws what vanilla draws. + */ + private static boolean withinViewArea(View view) { + Minecraft minecraft = Minecraft.getInstance(); + ClientLevel level = minecraft.level; + if (level == null) { + return true; + } + double horizontal = (minecraft.options.getEffectiveRenderDistance() + 2) * 16.0 + SECTION_REACH; + double vertical = Math.max(Math.abs(view.camY - level.getMinY()), Math.abs(view.camY - (level.getMaxY() + 1))) + + SECTION_REACH; + return 2.0 * horizontal * horizontal + vertical * vertical > view.cutoffSq; + } + + /** + * True while vanilla's main-view prepare runs with fog culling cutting sections. SectionDrawCache then builds the + * lists fresh: the cut depends on the exact camera position and fog distance, which its key does not hold. + */ + public static boolean mainViewCutting() { + return inMainView && MAIN.active; + } + + public static void endMainView() { + sectionsCulledLastFrame = sectionCount; + inMainView = false; + MAIN.active = false; + } + + // ------------------------------------------------------------------ tests + + /** True if every point of the box (world coordinates) is fully fogged and has a fog-coloured background. */ + private static boolean hidden(View view, double minX, double minY, double minZ, double maxX, double maxY, double maxZ) { + double x0 = minX - view.camX; + double y0 = minY - view.camY; + double z0 = minZ - view.camZ; + double x1 = maxX - view.camX; + double y1 = maxY - view.camY; + double z1 = maxZ - view.camZ; + double dx = x0 > 0.0 ? x0 : x1 < 0.0 ? -x1 : 0.0; + double dy = y0 > 0.0 ? y0 : y1 < 0.0 ? -y1 : 0.0; + double dz = z0 > 0.0 ? z0 : z1 < 0.0 ? -z1 : 0.0; + if (!(dx * dx + dy * dy + dz * dz > view.cutoffSq)) { + return false; + } + if (view.maxTan == Double.POSITIVE_INFINITY) { + return true; + } + // Highest elevation seen from the camera: the top face, at its nearest horizontal point when above the eye, + // at its farthest when below it. + if (y1 > 0.0) { + double nearest = Math.sqrt(dx * dx + dz * dz); + return nearest > 0.0 && y1 <= view.maxTan * nearest; + } + double farthest = Math.sqrt(Math.max(x0 * x0, x1 * x1) + Math.max(z0 * z0, z1 * z1)); + return y1 <= view.maxTan * farthest; + } + + /** LevelRenderer.extractSectionDrawGroups: true to leave this section out of the main view. */ + public static boolean sectionHidden(SectionRenderDispatcher.RenderSection section) { + if (!inMainView || !MAIN.active) { + return false; + } + BlockPos origin = section.getRenderOrigin(); + double x = origin.getX(); + double y = origin.getY(); + double z = origin.getZ(); + if (hidden(MAIN, x - SECTION_REACH, y - SECTION_REACH, z - SECTION_REACH, x + 16 + SECTION_REACH, + y + 16 + SECTION_REACH, z + 16 + SECTION_REACH)) { + sectionCount++; + return true; + } + return false; + } + + /** LevelExtractor.extractVisibleEntities, for an entity vanilla is about to extract: true to skip it. */ + public static boolean entityHidden(Entity entity, float partialTicks, EntityRenderDispatcher dispatcher) { + if (!EXTRACT.active || neverCull(entity)) { + return false; + } + CullEntityRendererInvoker renderer = (CullEntityRendererInvoker) dispatcher.getRenderer(entity); + // Only vanilla renderers: their every layer goes through the fogged entity shaders. + if (renderer == null || !VANILLA_CLASS.get(renderer.getClass()) || !renderer.vesuvio$affectedByCulling(entity)) { + return false; + } + AABB box = renderer.vesuvio$getBoundingBoxForCulling(entity, partialTicks); + if (box.hasNaN() || box.getXsize() > MAX_ENTITY_BOX || box.getYsize() > MAX_ENTITY_BOX + || box.getZsize() > MAX_ENTITY_BOX) { + return false; + } + // The shadow reaches about the entity's width out and down (radius scales with the entity). + double reach = ENTITY_REACH + Math.max(box.getXsize(), box.getZsize()); + if (hidden(EXTRACT, box.minX - reach, box.minY - reach, box.minZ - reach, box.maxX + reach, + box.maxY + ENTITY_REACH, box.maxZ + reach)) { + objectCount++; + return true; + } + return false; + } + + /** + * Entities drawn without fog somewhere (outline, see-through name), drawn outside their box (leads, beams, the + * fishing line, display transforms, the dragon), or with an additive layer (the charged creeper's and the wither's + * energy swirl add the fog colour on top of the background instead of replacing it). + */ + private static boolean neverCull(Entity entity) { + Minecraft mc = Minecraft.getInstance(); + if (entity instanceof LocalPlayer || entity == mc.getCameraEntity() || entity == mc.crosshairPickEntity + || mc.shouldEntityAppearGlowing(entity) || entity.shouldShowName() && !entity.isDiscrete()) { + return true; + } + LocalPlayer player = mc.player; + if (player != null && (entity.hasIndirectPassenger(player) || player.hasIndirectPassenger(entity))) { + return true; + } + if (entity instanceof Leashable leashable && leashable.getLeashHolder() != null) { + return true; + } + return entity instanceof Display || entity instanceof FishingHook || entity instanceof LightningBolt + || entity instanceof EnderDragon || entity instanceof EnderDragonPart + || entity instanceof EndCrystal crystal && crystal.getBeamTarget() != null + || entity instanceof Guardian guardian && guardian.hasActiveAttackTarget() + || entity instanceof Creeper creeper && creeper.isPowered() + || entity instanceof WitherBoss wither && wither.isPowered(); + } + + /** LevelExtractor.extractVisibleBlockEntities, per-section loop: true to skip this block entity. */ + public static boolean blockEntityHidden(BlockEntity blockEntity, BlockEntityRenderDispatcher dispatcher) { + if (!EXTRACT.active) { + return false; + } + BlockEntityRenderer renderer = dispatcher.getRenderer(blockEntity); + if (renderer == null || !VANILLA_CLASS.get(renderer.getClass()) || renderer.shouldRenderOffScreen() + || renderer.getViewDistance() > 64) { + return false; + } + // The block plus everything vanilla renderers draw around it (banners hang a block below and stand two high, + // moving pistons are a block away, the spawner's mob, the enchanting book). + BlockPos pos = blockEntity.getBlockPos(); + if (hidden(EXTRACT, pos.getX() - 1.5, pos.getY() - 2.0, pos.getZ() - 1.5, pos.getX() + 2.5, pos.getY() + 3.5, + pos.getZ() + 2.5)) { + objectCount++; + return true; + } + return false; + } + + /** QuadParticleGroup.extractRenderState, for a particle vanilla is about to extract: true to skip it. */ + public static boolean particleHidden(SingleQuadParticle particle, float partialTicks) { + if (!EXTRACT.active || !VANILLA_CLASS.get(particle.getClass())) { + return false; + } + ParticleAccessor pos = (ParticleAccessor) particle; + // Drawn at the interpolated position, a camera-facing quad reaching quadSize from it (corners: times sqrt 2). + double cx = Mth.lerp(partialTicks, pos.vesuvio$xo(), pos.vesuvio$x()); + double cy = Mth.lerp(partialTicks, pos.vesuvio$yo(), pos.vesuvio$y()); + double cz = Mth.lerp(partialTicks, pos.vesuvio$zo(), pos.vesuvio$z()); + double r = Math.abs(particle.getQuadSize(partialTicks)) * 1.5 + 0.5; + if (!(r < 16.0)) { + return false; + } + if (hidden(EXTRACT, cx - r, cy - r, cz - r, cx + r, cy + r, cz + r)) { + particleCount++; + return true; + } + return false; + } + + // ------------------------------------------------------------------ Etna + + /** Whether Etna Shaders is installed and drawing (its terrain fog is its own curve, not vanilla's). */ + static final class Etna { + private static final boolean LOADED = FabricLoader.getInstance().isModLoaded("etna"); + private static final MethodHandle ENABLED = LOADED ? find() : null; + + private Etna() { + } + + private static MethodHandle find() { + try { + Class config = Class.forName("it.cosimo.etna.EtnaConfig", false, FogCulling.class.getClassLoader()); + return MethodHandles.publicLookup().findStaticGetter(config, "enabled", boolean.class); + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + Vesuvio.LOGGER.info("Vesuvio: Etna Shaders found but its on/off switch is not readable; fog culling stays off"); + return null; + } + } + + static boolean active() { + if (!LOADED) { + return false; + } + if (ENABLED == null) { + return true; + } + try { + return (boolean) ENABLED.invokeExact(); + } catch (Throwable t) { + return true; + } + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/mixin/CullEntityRendererInvoker.java b/src/main/java/it/cosimo/vesuvio/culling/mixin/CullEntityRendererInvoker.java new file mode 100644 index 0000000..3035026 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/mixin/CullEntityRendererInvoker.java @@ -0,0 +1,18 @@ +package it.cosimo.vesuvio.culling.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; + +/** The box vanilla frustum-culls an entity with, and whether the renderer allows culling at all (fog culling). */ +@Mixin(EntityRenderer.class) +public interface CullEntityRendererInvoker { + @Invoker("getBoundingBoxForCulling") + AABB vesuvio$getBoundingBoxForCulling(Entity entity, float partialTicks); + + @Invoker("affectedByCulling") + boolean vesuvio$affectedByCulling(Entity entity); +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/mixin/CullEntityVisibilityMixin.java b/src/main/java/it/cosimo/vesuvio/culling/mixin/CullEntityVisibilityMixin.java new file mode 100644 index 0000000..5eba201 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/mixin/CullEntityVisibilityMixin.java @@ -0,0 +1,51 @@ +package it.cosimo.vesuvio.culling.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.culling.DetailCulling; +import it.cosimo.vesuvio.culling.FogCulling; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.extract.LevelExtractor; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.decoration.ItemFrame; + +/** + * Fog culling of entities and item frames behind their wall, where vanilla picks what to extract for the camera: + * vanilla's {@code isEntityVisible(entity, frustum, camX, camY, camZ, entityPartialTicks, chunkFadeDuration)} answer + * in extractVisibleEntities is narrowed. The arguments it needs are the loop's own locals, read here: a value hook, + * nothing is allocated per entity. The frame's fog state is set up by {@link CullLevelExtractorMixin}. + * + *

Priority 999: the same answer is also narrowed by entity occlusion culling (entities.mixin.LevelExtractorMixin, + * priority 1000). Of two value hooks on one call, the one applied later sees vanilla's value first, so this one is + * applied first and its test runs second: occlusion, then fog, the order the game ran them in when both were + * wrapping hooks (where the entities config, listed first, came first and ran innermost). + */ +@Mixin(value = LevelExtractor.class, priority = 999) +abstract class CullEntityVisibilityMixin { + @Shadow + @Final + private LevelRenderer levelRenderer; + + @Shadow + private ClientLevel level; + + @ModifyExpressionValue(method = "extractVisibleEntities", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/extract/LevelExtractor;isEntityVisible(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/client/renderer/culling/Frustum;DDDFJ)Z")) + private boolean vesuvio$cullEntity(boolean visible, @Local Entity entity, @Local float partialTicks, + @Local(ordinal = 0) double camX, @Local(ordinal = 1) double camY, @Local(ordinal = 2) double camZ) { + if (!visible) { + return false; + } + if (FogCulling.entityHidden(entity, partialTicks, this.levelRenderer.entityRenderDispatcher())) { + return false; + } + return !(entity instanceof ItemFrame frame && DetailCulling.itemFrameHidden(frame, camX, camY, camZ, this.level)); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/mixin/CullLevelExtractorMixin.java b/src/main/java/it/cosimo/vesuvio/culling/mixin/CullLevelExtractorMixin.java new file mode 100644 index 0000000..ff7dc70 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/mixin/CullLevelExtractorMixin.java @@ -0,0 +1,85 @@ +package it.cosimo.vesuvio.culling.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.culling.DetailCulling; +import it.cosimo.vesuvio.culling.FogCulling; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.extract.LevelExtractor; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.state.level.LevelRenderState; +import net.minecraft.world.level.block.entity.BlockEntity; + +/** + * Fog culling of block entities and beacon beams out of view, where vanilla picks what to extract for the camera: + * around tryExtractRenderState in extractVisibleBlockEntities (the per-section loop for fog, the globally rendered + * list for beacons). Starts and ends the frame's fog and detail culling. Entities: {@link CullEntityVisibilityMixin}. + */ +@Mixin(LevelExtractor.class) +abstract class CullLevelExtractorMixin { + @Shadow + @Final + private Minecraft minecraft; + + @Shadow + @Final + private LevelRenderer levelRenderer; + + @Shadow + @Final + private LevelRenderState levelRenderState; + + @Shadow + private ClientLevel level; + + @Inject(method = "extract", at = @At("HEAD")) + private void vesuvio$beginCulling(DeltaTracker deltaTracker, Camera camera, float worldPartialTicks, CallbackInfo ci) { + FogCulling.beginExtract(this.minecraft, this.level, this.levelRenderState, worldPartialTicks); + DetailCulling.beginExtract(); + } + + @Inject(method = "extract", at = @At("RETURN")) + private void vesuvio$endCulling(DeltaTracker deltaTracker, Camera camera, float worldPartialTicks, CallbackInfo ci) { + FogCulling.endExtract(); + DetailCulling.endExtract(); + } + + @WrapOperation(method = "extractVisibleBlockEntities", at = @At(value = "INVOKE", ordinal = 0, + target = "Lnet/minecraft/client/renderer/blockentity/BlockEntityRenderDispatcher;tryExtractRenderState(Lnet/minecraft/world/level/block/entity/BlockEntity;FLnet/minecraft/client/renderer/feature/ModelFeatureRenderer$CrumblingOverlay;Z)Lnet/minecraft/client/renderer/blockentity/state/BlockEntityRenderState;")) + private BlockEntityRenderState vesuvio$fogCullBlockEntity(BlockEntityRenderDispatcher dispatcher, + BlockEntity blockEntity, float partialTicks, ModelFeatureRenderer.CrumblingOverlay breakProgress, + boolean isGloballyRendered, Operation original) { + if (!isGloballyRendered && breakProgress == null && FogCulling.blockEntityHidden(blockEntity, dispatcher)) { + return null; + } + return original.call(dispatcher, blockEntity, partialTicks, breakProgress, isGloballyRendered); + } + + @WrapOperation(method = "extractVisibleBlockEntities", at = @At(value = "INVOKE", ordinal = 1, + target = "Lnet/minecraft/client/renderer/blockentity/BlockEntityRenderDispatcher;tryExtractRenderState(Lnet/minecraft/world/level/block/entity/BlockEntity;FLnet/minecraft/client/renderer/feature/ModelFeatureRenderer$CrumblingOverlay;Z)Lnet/minecraft/client/renderer/blockentity/state/BlockEntityRenderState;")) + private BlockEntityRenderState vesuvio$cullBeaconBeam(BlockEntityRenderDispatcher dispatcher, + BlockEntity blockEntity, float partialTicks, ModelFeatureRenderer.CrumblingOverlay breakProgress, + boolean isGloballyRendered, Operation original, + @Local(argsOnly = true) Camera camera) { + if (DetailCulling.beaconHidden(blockEntity, dispatcher, this.levelRenderState.cameraRenderState, + this.levelRenderState.playerRenderState, camera.forwardVector())) { + return null; + } + return original.call(dispatcher, blockEntity, partialTicks, breakProgress, isGloballyRendered); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/mixin/CullLevelRendererMixin.java b/src/main/java/it/cosimo/vesuvio/culling/mixin/CullLevelRendererMixin.java new file mode 100644 index 0000000..a510a0e --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/mixin/CullLevelRendererMixin.java @@ -0,0 +1,91 @@ +package it.cosimo.vesuvio.culling.mixin; + +import org.joml.Matrix4fc; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; + +import it.cosimo.vesuvio.culling.DetailCulling; +import it.cosimo.vesuvio.culling.FogCulling; +import net.minecraft.client.renderer.GameRenderer; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.chunk.ChunkSectionsToRender; +import net.minecraft.client.renderer.gizmos.DrawableGizmoPrimitives; +import net.minecraft.client.renderer.state.OptionsRenderState; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.state.level.LevelRenderState; + +/** + * Fog culling, terrain side. Vanilla's own two prepare calls in render() are wrapped to mark the main view (a shader + * mod's shadow pass prepares outside the mark and keeps every section); inside, extractSectionDrawGroups sees a + * fully fogged section's mesh as empty, so it adds no draw for it ({@link CullSectionMeshMixin}). Also notes whether the frame draws debug gizmos + * (no fog on them), and brackets submitFeatures for the sign text counter. + */ +@Mixin(LevelRenderer.class) +abstract class CullLevelRendererMixin { + @Shadow + @Final + private LevelRenderState levelRenderState; + + @Shadow + @Final + private OptionsRenderState optionsRenderState; + + @Shadow + @Final + private GameRenderer gameRenderer; + + @WrapOperation(method = "render", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/LevelRenderer;prepareChunkRendersIndirect(Lorg/joml/Matrix4fc;Z)Lnet/minecraft/client/renderer/chunk/ChunkSectionsToRender;")) + private ChunkSectionsToRender vesuvio$fogMainViewIndirect(LevelRenderer self, Matrix4fc modelView, + boolean respectTranslucentOrder, Operation original) { + FogCulling.beginMainView(this.levelRenderState, this.gameRenderer.useImprovedTransparency(), + this.optionsRenderState.cloudStatus); + try { + return original.call(self, modelView, respectTranslucentOrder); + } finally { + FogCulling.endMainView(); + } + } + + @WrapOperation(method = "render", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/LevelRenderer;prepareChunkRenders(Lorg/joml/Matrix4fc;Z)Lnet/minecraft/client/renderer/chunk/ChunkSectionsToRender;")) + private ChunkSectionsToRender vesuvio$fogMainView(LevelRenderer self, Matrix4fc modelView, + boolean respectTranslucentOrder, Operation original) { + FogCulling.beginMainView(this.levelRenderState, this.gameRenderer.useImprovedTransparency(), + this.optionsRenderState.cloudStatus); + try { + return original.call(self, modelView, respectTranslucentOrder); + } finally { + FogCulling.endMainView(); + } + } + + @Inject(method = "submitFeatures", at = @At("HEAD")) + private void vesuvio$beginSubmit(LevelRenderState state, SubmitNodeCollector collector, boolean renderOutline, + CallbackInfo ci) { + DetailCulling.beginSubmit(); + } + + @Inject(method = "submitFeatures", at = @At("RETURN")) + private void vesuvio$endSubmit(LevelRenderState state, SubmitNodeCollector collector, boolean renderOutline, + CallbackInfo ci) { + DetailCulling.endSubmit(); + } + + /** {@code this.finalizedGizmos.standardPrimitives().submit(...)}: depth-tested lines and boxes, some unfogged. */ + @WrapOperation(method = "submitFeatures", at = @At(value = "INVOKE", ordinal = 0, + target = "Lnet/minecraft/client/renderer/gizmos/DrawableGizmoPrimitives;submit(Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/level/CameraRenderState;Z)V")) + private void vesuvio$noteGizmos(DrawableGizmoPrimitives primitives, SubmitNodeCollector collector, + CameraRenderState camera, boolean onTop, Operation original) { + FogCulling.noteGizmos(!primitives.isEmpty()); + original.call(primitives, collector, camera, onTop); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/mixin/CullSectionMeshMixin.java b/src/main/java/it/cosimo/vesuvio/culling/mixin/CullSectionMeshMixin.java new file mode 100644 index 0000000..79c576a --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/mixin/CullSectionMeshMixin.java @@ -0,0 +1,32 @@ +package it.cosimo.vesuvio.culling.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.culling.FogCulling; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.chunk.CompiledSectionMesh; +import net.minecraft.client.renderer.chunk.SectionMesh; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; + +/** + * Fog culling, terrain side: inside the main view's prepare (marked by {@link CullLevelRendererMixin}), + * extractSectionDrawGroups sees a fully fogged section's mesh ({@code section.getSectionMesh()}, a plain read of the + * section's mesh reference) as empty, so it adds no draw for it. A value hook: nothing is allocated per section. + * + *

Priority 1001: the animated texture tracking (frame.mixin.FrameLevelRendererMixin, priority 1000) reads the same + * value to mark the section's sprites as seen. Of two value hooks on one call, the one applied later sees vanilla's + * value first, so this one is applied last: a fogged section reaches that hook already empty and marks nothing, as it + * did when this was a wrapping hook (applied after every value hook, so innermost). + */ +@Mixin(value = LevelRenderer.class, priority = 1001) +abstract class CullSectionMeshMixin { + @ModifyExpressionValue(method = "extractSectionDrawGroups", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/chunk/SectionRenderDispatcher$RenderSection;getSectionMesh()Lnet/minecraft/client/renderer/chunk/SectionMesh;")) + private SectionMesh vesuvio$fogCullSection(SectionMesh mesh, @Local SectionRenderDispatcher.RenderSection section) { + return FogCulling.sectionHidden(section) ? CompiledSectionMesh.EMPTY : mesh; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/mixin/FogParticleGroupMixin.java b/src/main/java/it/cosimo/vesuvio/culling/mixin/FogParticleGroupMixin.java new file mode 100644 index 0000000..459b865 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/mixin/FogParticleGroupMixin.java @@ -0,0 +1,30 @@ +package it.cosimo.vesuvio.culling.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.culling.FogCulling; +import net.minecraft.client.particle.QuadParticleGroup; +import net.minecraft.client.particle.SingleQuadParticle; + +/** + * Fog culling of particles, after vanilla's own frustum test of each one ({@code frustum.pointInFrustum(particle.x, + * particle.y, particle.z)}): a particle it lets through can still be dropped. The test reads the same position from + * the particle. A plain value hook: nothing is allocated per particle. + * + *

Priority 999: particle occlusion culling (entities.mixin.QuadParticleGroupMixin, priority 1000) narrows the same + * answer. Of two value hooks on one call, the one applied later sees vanilla's value first, so this one is applied + * first and its test runs second: occlusion, then fog, the order the game ran them in when both were wrapping hooks. + */ +@Mixin(value = QuadParticleGroup.class, priority = 999) +abstract class FogParticleGroupMixin { + @ModifyExpressionValue(method = "extractRenderState", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/culling/Frustum;pointInFrustum(DDD)Z")) + private boolean vesuvio$fogCullParticle(boolean inFrustum, @Local SingleQuadParticle particle, + @Local(argsOnly = true) float partialTickTime) { + return inFrustum && !FogCulling.particleHidden(particle, partialTickTime); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/mixin/FogRendererAccessor.java b/src/main/java/it/cosimo/vesuvio/culling/mixin/FogRendererAccessor.java new file mode 100644 index 0000000..10fb296 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/mixin/FogRendererAccessor.java @@ -0,0 +1,15 @@ +package it.cosimo.vesuvio.culling.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import net.minecraft.client.renderer.fog.FogRenderer; + +/** The F3 fog toggle: while it is off the shaders get the empty fog block and nothing is fogged. */ +@Mixin(FogRenderer.class) +public interface FogRendererAccessor { + @Accessor("fogEnabled") + static boolean vesuvio$fogEnabled() { + throw new AssertionError(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/mixin/ItemFrameItemMixin.java b/src/main/java/it/cosimo/vesuvio/culling/mixin/ItemFrameItemMixin.java new file mode 100644 index 0000000..7413897 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/mixin/ItemFrameItemMixin.java @@ -0,0 +1,38 @@ +package it.cosimo.vesuvio.culling.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import com.llamalad7.mixinextras.sugar.Local; +import com.mojang.blaze3d.vertex.PoseStack; + +import it.cosimo.vesuvio.culling.DetailCulling; +import net.minecraft.client.renderer.MapRenderer; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.ItemFrameRenderer; +import net.minecraft.client.renderer.entity.state.ItemFrameRenderState; +import net.minecraft.client.renderer.item.ItemStackRenderState; +import net.minecraft.client.renderer.state.MapRenderState; +import net.minecraft.client.renderer.state.level.CameraRenderState; + +/** Item frame culling, submit side: the framed map or item is left out once it is smaller than a pixel. */ +@Mixin(ItemFrameRenderer.class) +abstract class ItemFrameItemMixin { + @WrapWithCondition(method = "submit(Lnet/minecraft/client/renderer/entity/state/ItemFrameRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/level/CameraRenderState;)V", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/MapRenderer;render(Lnet/minecraft/client/renderer/state/MapRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;ZI)V")) + private boolean vesuvio$cullFramedMap(MapRenderer renderer, MapRenderState map, PoseStack poseStack, + SubmitNodeCollector collector, boolean showOnlyFrame, int lightCoords, + @Local(argsOnly = true) ItemFrameRenderState state, @Local(argsOnly = true) CameraRenderState camera) { + return !DetailCulling.framedItemHidden(poseStack.last().pose(), state.outlineColor, camera); + } + + @WrapWithCondition(method = "submit(Lnet/minecraft/client/renderer/entity/state/ItemFrameRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/level/CameraRenderState;)V", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/item/ItemStackRenderState;submit(Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;III)V")) + private boolean vesuvio$cullFramedItem(ItemStackRenderState item, PoseStack poseStack, SubmitNodeCollector collector, + int lightCoords, int overlayCoords, int outlineColor, @Local(argsOnly = true) CameraRenderState camera) { + return !DetailCulling.framedItemHidden(poseStack.last().pose(), outlineColor, camera); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/mixin/ParticleAccessor.java b/src/main/java/it/cosimo/vesuvio/culling/mixin/ParticleAccessor.java new file mode 100644 index 0000000..808370f --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/mixin/ParticleAccessor.java @@ -0,0 +1,28 @@ +package it.cosimo.vesuvio.culling.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import net.minecraft.client.particle.Particle; + +/** A particle's position last tick and now: it is drawn between the two. */ +@Mixin(Particle.class) +public interface ParticleAccessor { + @Accessor("xo") + double vesuvio$xo(); + + @Accessor("yo") + double vesuvio$yo(); + + @Accessor("zo") + double vesuvio$zo(); + + @Accessor("x") + double vesuvio$x(); + + @Accessor("y") + double vesuvio$y(); + + @Accessor("z") + double vesuvio$z(); +} diff --git a/src/main/java/it/cosimo/vesuvio/culling/mixin/SignTextMixin.java b/src/main/java/it/cosimo/vesuvio/culling/mixin/SignTextMixin.java new file mode 100644 index 0000000..a2d375e --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/culling/mixin/SignTextMixin.java @@ -0,0 +1,32 @@ +package it.cosimo.vesuvio.culling.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import com.llamalad7.mixinextras.sugar.Local; +import com.mojang.blaze3d.vertex.PoseStack; + +import it.cosimo.vesuvio.culling.DetailCulling; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.blockentity.AbstractSignRenderer; +import net.minecraft.client.renderer.blockentity.state.SignRenderState; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.world.level.block.entity.SignText; + +/** + * Sign text culling: each side's text (front, then back) is submitted only if it can cover a pixel. The sign board is + * terrain; text is all this renderer draws. + */ +@Mixin(AbstractSignRenderer.class) +abstract class SignTextMixin { + @WrapWithCondition( + method = "submit(Lnet/minecraft/client/renderer/blockentity/state/SignRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/level/CameraRenderState;)V", + at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/blockentity/AbstractSignRenderer;submitSignText(Lnet/minecraft/client/renderer/blockentity/state/SignRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/world/level/block/entity/SignText;)V") + ) + private boolean vesuvio$cullSignText(AbstractSignRenderer self, SignRenderState state, PoseStack poseStack, + SubmitNodeCollector collector, SignText text, @Local(argsOnly = true) CameraRenderState camera) { + return !DetailCulling.signTextHidden(state, poseStack.last().pose(), camera); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/draws/Draws.java b/src/main/java/it/cosimo/vesuvio/draws/Draws.java new file mode 100644 index 0000000..1279423 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/draws/Draws.java @@ -0,0 +1,16 @@ +package it.cosimo.vesuvio.draws; + +/** + * Per-draw cost in the Vulkan backend: binds and descriptor sets the GPU already has ({@link RedundantBinds}), and + * the submit timers that show where the end of each frame goes ({@link SubmitTimers}). + */ +public final class Draws { + private Draws() { + } + + /** Creates the settings and registers the counters (Vesuvio.onInitializeClient). */ + public static void init() { + RedundantBinds.init(); + SubmitTimers.init(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/draws/RedundantBinds.java b/src/main/java/it/cosimo/vesuvio/draws/RedundantBinds.java new file mode 100644 index 0000000..25f5887 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/draws/RedundantBinds.java @@ -0,0 +1,135 @@ +package it.cosimo.vesuvio.draws; + +import java.util.List; + +import com.mojang.renderpearl.api.buffers.GpuBufferSlice; +import com.mojang.renderpearl.api.pipeline.CompiledRenderPipeline; +import com.mojang.renderpearl.util.TextureViewAndSampler; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.ui.VesuvioStats; +import org.jspecify.annotations.Nullable; + +/** + * Setting skipRedundantBinds: the Vulkan render pass stops sending state the GPU already has. + * + *

Vanilla's Vulkan backend (VulkanRenderPass) binds the pipeline on every setPipeline, even when it is the one + * already bound, and before every draw that follows a setPipeline or setUniform it allocates a new descriptor set, + * writes every binding into it and binds it, even when every binding names the same buffer range or texture as the + * set bound for the previous draw. Entities, particles and the GUI do setPipeline plus all their uniforms for every + * single draw, so most of those binds and sets repeat the last ones. On MoltenVK each one is Metal encoding work on + * the CPU; on desktop drivers it is driver work. + * + *

Within one render pass ({@code VulkanRenderPassMixin}): + *

    + *
  • setPipeline with the pipeline already bound in this pass skips vkCmdBindPipeline. Binding the same pipeline + * again changes nothing in Vulkan: its static state is the same, its dynamic state (viewport, scissor) is left as + * set, and bound descriptor sets and push constants stay bound.
  • + *
  • A descriptor set is not made again when the pipeline is the one the bound set was made for, no pipeline was + * bound in between, and every binding has the same value: the same buffer object, offset and length, or the same + * texture view and sampler objects. The set already bound then holds exactly the descriptors vanilla would write + * (descriptor sets are never written again after their first update, and buffers, views and samplers are only + * destroyed after the frame is done), so binding a new copy of it changes nothing.
  • + *
+ * A missing uniform still reaches vanilla's code and fails the same way. Off (or with the master switch off), + * vanilla's code runs as is. + */ +public final class RedundantBinds { + public static final VesuvioConfig.Toggle SKIP = VesuvioConfig.toggle("rendering", "skipRedundantBinds", true, false); + + // Render thread: counted during a frame, published when it is submitted (VulkanCommandEncoder.submit). + static int pipelineBindsSkipped; + static int descriptorSetsSkipped; + static int draws; + /** Pipeline binds left out last frame. */ + public static int pipelineBindsSkippedLastFrame; + /** Descriptor sets not allocated, written and bound last frame. */ + public static int descriptorSetsSkippedLastFrame; + /** Draws recorded last frame, counting each indirect sub-draw (on MoltenVK, one Metal draw each). */ + public static int drawsLastFrame; + + private RedundantBinds() { + } + + /** Called from Draws.init: makes the setting and registers the counters. */ + static void init() { + VesuvioStats.register("binds skipped", () -> pipelineBindsSkippedLastFrame); + VesuvioStats.register("desc sets skipped", () -> descriptorSetsSkippedLastFrame); + VesuvioStats.register("draws", () -> drawsLastFrame); + } + + public static void pipelineBindSkipped() { + pipelineBindsSkipped++; + } + + public static void descriptorSetSkipped() { + descriptorSetsSkipped++; + } + + public static void drew(int count) { + draws += count; + } + + /** Start of VulkanCommandEncoder.submit: the frame's commands are all recorded. */ + public static void endFrame() { + pipelineBindsSkippedLastFrame = pipelineBindsSkipped; + descriptorSetsSkippedLastFrame = descriptorSetsSkipped; + drawsLastFrame = draws; + pipelineBindsSkipped = 0; + descriptorSetsSkipped = 0; + draws = 0; + } + + /** + * The values a descriptor set written now would hold, in the order of the pipeline's uniforms, reusing + * {@code into} when it is long enough. + */ + public static Object[] snapshot(List uniforms, List values, + Object @Nullable [] into) { + int n = uniforms.size(); + Object[] out = into != null && into.length >= n ? into : new Object[n]; + for (int i = 0; i < n; i++) { + out[i] = values.get(uniforms.get(i).binding()); + } + return out; + } + + /** Whether every uniform of the pipeline has the value recorded by {@link #snapshot}. */ + public static boolean sameValues(List uniforms, List values, + Object @Nullable [] snapshot) { + if (snapshot == null) { + return false; + } + int n = uniforms.size(); + if (snapshot.length < n) { + return false; + } + for (int i = 0; i < n; i++) { + if (!same(values.get(uniforms.get(i).binding()), snapshot[i])) { + return false; + } + } + return true; + } + + /** + * Two uniform values that give the same descriptor: the same buffer range (buffer object, offset, length), or the + * same texture view and sampler. The front end makes a new GpuBufferSlice or TextureViewAndSampler for each + * setUniform, so the objects themselves are rarely the same. A null value never matches (vanilla must see it). + */ + private static boolean same(@Nullable Object a, @Nullable Object b) { + if (a == null || b == null) { + return false; + } + if (a == b) { + return true; + } + if (a instanceof GpuBufferSlice sa && b instanceof GpuBufferSlice sb) { + return sa.buffer() == sb.buffer() && sa.offset() == sb.offset() && sa.length() == sb.length(); + } + if (a instanceof TextureViewAndSampler ta && b instanceof TextureViewAndSampler tb) { + return ta.view() == tb.view() && ta.sampler() == tb.sampler(); + } + return false; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/draws/SubmitTimers.java b/src/main/java/it/cosimo/vesuvio/draws/SubmitTimers.java new file mode 100644 index 0000000..41dc055 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/draws/SubmitTimers.java @@ -0,0 +1,111 @@ +package it.cosimo.vesuvio.draws; + +import it.cosimo.vesuvio.ui.VesuvioStats; + +/** + * Where the end of a frame goes: two timers in VulkanCommandEncoder.submit, which the game calls once a frame after + * recording all of it (VulkanCommandEncoderMixin). Measurement only; nothing about the frame changes. + * + *
    + *
  • encode: the time spent inside vkQueueSubmit (VulkanQueue.Submission.close). On macOS with MoltenVK's + * default synchronous submits this is where every Vulkan command of the frame is turned into Metal, and also where + * MoltenVK waits for a free swapchain image when the display is the limit (vsync). With asyncSubmit on, that work + * moves to MoltenVK's own thread and this timer only sees the hand-over. On Windows and Linux it is the driver's + * submit, usually small.
  • + *
  • wait: the time spent waiting for the GPU to finish the frame before last (the game keeps two frames in + * flight), before the next frame can start. Large when the GPU is the limit, near zero when the CPU is.
  • + *
+ * + *

Shown on F3 and in the settings screen's counters as averages over the last second ("submit encode us", + * "submit wait us", and "gpu bound" 1 or 0 for {@link #gpuBound}), and in the benchmark table as ms per frame for + * each step. {@link #gpuBound} turns the same numbers into the face culling split decision on the Mac + * (FaceCulling). + */ +public final class SubmitTimers { + private static final long WINDOW_NS = 1_000_000_000L; + /** The GPU counts as the limit from this share of the frame spent waiting for it, and stops below the second. */ + private static final double GPU_BOUND_ENTER = 0.10; + private static final double GPU_BOUND_LEAVE = 0.04; + /** Without a measured window this recent (no frames, or the hook is off), {@link #gpuBound} says true. */ + private static final long STALE_NS = 3_000_000_000L; + + // Render thread only. Totals since start, for the benchmark (differences between two reads). + /** Nanoseconds inside vkQueueSubmit, all frames. */ + public static long encodeNsTotal; + /** Nanoseconds waiting for an earlier frame's GPU work, all frames. */ + public static long waitNsTotal; + /** Frames submitted. */ + public static long submits; + + /** Averages over the last full second, in microseconds per frame (F3). */ + public static int encodeUs; + public static int waitUs; + + private static long windowStart; + private static long windowEncode; + private static long windowWait; + private static int windowFrames; + private static long lastWindowEnd; + private static volatile boolean gpuBound = true; + + private SubmitTimers() { + } + + static void init() { + VesuvioStats.register("submit encode us", () -> encodeUs); + VesuvioStats.register("submit wait us", () -> waitUs); + VesuvioStats.register("gpu bound", () -> gpuBound() ? 1 : 0); + } + + /** One vkQueueSubmit of VulkanCommandEncoder.submit took this long. */ + public static void encoded(long ns) { + encodeNsTotal += ns; + windowEncode += ns; + } + + /** One wait for an earlier submit in VulkanCommandEncoder.submit took this long. */ + public static void waited(long ns) { + waitNsTotal += ns; + windowWait += ns; + } + + /** End of VulkanCommandEncoder.submit: one frame more; closes the one-second window when it is over. */ + public static void submitted(long now) { + submits++; + windowFrames++; + if (windowStart == 0L) { + windowStart = now; + windowEncode = 0L; + windowWait = 0L; + windowFrames = 0; + return; + } + long length = now - windowStart; + if (length < WINDOW_NS) { + return; + } + encodeUs = (int) (windowEncode / windowFrames / 1000L); + waitUs = (int) (windowWait / windowFrames / 1000L); + double waitShare = (double) windowWait / length; + if (gpuBound ? waitShare < GPU_BOUND_LEAVE : waitShare >= GPU_BOUND_ENTER) { + gpuBound = !gpuBound; + } + lastWindowEnd = now; + windowStart = now; + windowEncode = 0L; + windowWait = 0L; + windowFrames = 0; + } + + /** + * Whether the GPU was the limit over the last second: the render thread spent at least 10% of the time waiting + * for it (and stays "GPU-bound" until that falls under 4%, so the answer does not flip every second). True when + * nothing was measured lately, so callers fall back to what they did before this measure existed. + */ + public static boolean gpuBound() { + if (lastWindowEnd == 0L || System.nanoTime() - lastWindowEnd > STALE_NS) { + return true; + } + return gpuBound; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/draws/mixin/VulkanCommandEncoderMixin.java b/src/main/java/it/cosimo/vesuvio/draws/mixin/VulkanCommandEncoderMixin.java new file mode 100644 index 0000000..c63df7b --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/draws/mixin/VulkanCommandEncoderMixin.java @@ -0,0 +1,56 @@ +package it.cosimo.vesuvio.draws.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; +import com.mojang.renderpearl.backend.vulkan.VulkanQueue; + +import it.cosimo.vesuvio.draws.RedundantBinds; +import it.cosimo.vesuvio.draws.SubmitTimers; + +/** + * The frame's submit ({@link SubmitTimers}): times vkQueueSubmit ("encode") and the wait for the frame before last + * ("wait"), and closes the frame's draw and bind counters ({@link RedundantBinds}). Timing only; the calls run as in + * vanilla, always (the timers cost four clock reads a frame). + */ +@Mixin(VulkanCommandEncoder.class) +public abstract class VulkanCommandEncoderMixin { + @Inject(method = "submit", at = @At("HEAD")) + private void vesuvio$frameRecorded(CallbackInfo ci) { + RedundantBinds.endFrame(); + } + + @WrapOperation(method = "submit", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanQueue$Submission;close()V")) + private void vesuvio$timeEncode(VulkanQueue.Submission submission, Operation original) { + long start = System.nanoTime(); + try { + original.call(submission); + } finally { + SubmitTimers.encoded(System.nanoTime() - start); + } + } + + /** Both waits of submit (the second only runs after a 5 s timeout). */ + @WrapOperation(method = "submit", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanCommandEncoder;awaitSubmitCompletion(JJ)Z")) + private boolean vesuvio$timeWait(VulkanCommandEncoder self, long submitIndex, long timeoutNs, + Operation original) { + long start = System.nanoTime(); + try { + return original.call(self, submitIndex, timeoutNs); + } finally { + SubmitTimers.waited(System.nanoTime() - start); + } + } + + @Inject(method = "submit", at = @At("RETURN")) + private void vesuvio$frameSubmitted(CallbackInfo ci) { + SubmitTimers.submitted(System.nanoTime()); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/draws/mixin/VulkanRenderPassMixin.java b/src/main/java/it/cosimo/vesuvio/draws/mixin/VulkanRenderPassMixin.java new file mode 100644 index 0000000..9cec897 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/draws/mixin/VulkanRenderPassMixin.java @@ -0,0 +1,151 @@ +package it.cosimo.vesuvio.draws.mixin; + +import java.nio.IntBuffer; + +import org.lwjgl.vulkan.VkCommandBuffer; +import org.objectweb.asm.Opcodes; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import com.mojang.renderpearl.api.buffers.GpuBufferSlice; +import com.mojang.renderpearl.backend.api.BackendRenderPipeline; +import com.mojang.renderpearl.backend.vulkan.VulkanRenderPass; +import com.mojang.renderpearl.backend.vulkan.VulkanRenderPipeline; + +import it.cosimo.vesuvio.draws.RedundantBinds; +import it.unimi.dsi.fastutil.objects.ReferenceList; +import org.jspecify.annotations.Nullable; + +/** + * Setting skipRedundantBinds ({@link RedundantBinds}): in one Vulkan render pass, no second vkCmdBindPipeline for + * the pipeline already bound, and no new descriptor set when the bound one already holds the same values. Also + * counts the frame's draws (each indirect sub-draw is one; on MoltenVK each is one Metal draw). + * + *

The state kept here belongs to this pass object, which vanilla makes new for each render pass, so nothing is + * assumed about what an earlier pass left bound. + */ +@Mixin(VulkanRenderPass.class) +public abstract class VulkanRenderPassMixin { + @Shadow + @Nullable + protected VulkanRenderPipeline pipeline; + + @Shadow + private boolean anyDescriptorDirty; + + @Shadow + @Final + protected ReferenceList uniforms; + + /** setPipeline was asked for the pipeline this pass already bound. */ + @Unique + private boolean vesuvio$samePipeline; + + /** The pipeline the bound descriptor set was made for, or null when none may be reused. */ + @Unique + private @Nullable VulkanRenderPipeline vesuvio$setPipeline; + + /** The values written into the bound descriptor set, in the order of that pipeline's uniforms. */ + @Unique + private Object @Nullable [] vesuvio$setValues; + + /** The descriptor push under way is a real one, to be remembered when it returns. */ + @Unique + private boolean vesuvio$pushing; + + @Inject(method = "setPipeline", at = @At("HEAD")) + private void vesuvio$checkSamePipeline(BackendRenderPipeline next, CallbackInfo ci) { + // this.pipeline is only ever set by setPipeline of this pass, right before it binds it. + vesuvio$samePipeline = next != null && next == this.pipeline; + } + + @WrapWithCondition(method = "setPipeline", at = @At(value = "INVOKE", + target = "Lorg/lwjgl/vulkan/VK12;vkCmdBindPipeline(Lorg/lwjgl/vulkan/VkCommandBuffer;IJ)V")) + private boolean vesuvio$skipSamePipeline(VkCommandBuffer commandBuffer, int bindPoint, long vkPipeline) { + if (vesuvio$samePipeline && RedundantBinds.SKIP.on()) { + RedundantBinds.pipelineBindSkipped(); + return false; + } + // A real bind: from here on only a set made after it may be reused. + vesuvio$setPipeline = null; + return true; + } + + /** + * pushDescriptors' first step, its "anything dirty?" test: answering false makes vanilla return right there, + * as a cancel at HEAD would, but without a CallbackInfo per draw (the RETURN hook below then sees + * vesuvio$pushing false and does nothing). + */ + @ModifyExpressionValue(method = "pushDescriptors", at = @At(value = "FIELD", opcode = Opcodes.GETFIELD, + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanRenderPass;anyDescriptorDirty:Z")) + private boolean vesuvio$skipSameDescriptors(boolean dirty) { + vesuvio$pushing = false; + if (!dirty || this.pipeline == null) { + return dirty; + } + if (!RedundantBinds.SKIP.on()) { + vesuvio$setPipeline = null; + return true; + } + if (vesuvio$setPipeline == this.pipeline + && RedundantBinds.sameValues(this.pipeline.uniforms(), this.uniforms, vesuvio$setValues)) { + this.anyDescriptorDirty = false; + RedundantBinds.descriptorSetSkipped(); + return false; + } + vesuvio$pushing = true; + return true; + } + + @Inject(method = "pushDescriptors", at = @At("RETURN")) + private void vesuvio$rememberDescriptors(CallbackInfo ci) { + if (vesuvio$pushing) { + vesuvio$pushing = false; + vesuvio$setPipeline = this.pipeline; + vesuvio$setValues = RedundantBinds.snapshot(this.pipeline.uniforms(), this.uniforms, vesuvio$setValues); + } + } + + // Draw counter (each method calls pushDescriptors first, so it runs for every draw recorded). + + @Inject(method = "drawIndexed", at = @At("HEAD")) + private void vesuvio$countDrawIndexed(int indexCount, int instanceCount, int firstIndex, int vertexOffset, + int firstInstance, CallbackInfo ci) { + RedundantBinds.drew(1); + } + + @Inject(method = "draw", at = @At("HEAD")) + private void vesuvio$countDraw(int vertexCount, int instanceCount, int firstVertex, int firstInstance, + CallbackInfo ci) { + RedundantBinds.drew(1); + } + + @Inject(method = "drawIndexedIndirect", at = @At("HEAD")) + private void vesuvio$countDrawIndexedIndirect(GpuBufferSlice commands, int drawCount, CallbackInfo ci) { + RedundantBinds.drew(drawCount); + } + + @Inject(method = "drawIndirect", at = @At("HEAD")) + private void vesuvio$countDrawIndirect(GpuBufferSlice commands, int drawCount, CallbackInfo ci) { + RedundantBinds.drew(drawCount); + } + + @Inject(method = "multiDrawIndexed(Ljava/nio/IntBuffer;III)V", at = @At("HEAD")) + private void vesuvio$countMultiDrawIndexed(IntBuffer drawParameters, int instanceCount, int firstInstance, + int drawCount, CallbackInfo ci) { + RedundantBinds.drew(drawCount); + } + + @Inject(method = "multiDraw(Ljava/nio/IntBuffer;III)V", at = @At("HEAD")) + private void vesuvio$countMultiDraw(IntBuffer drawParameters, int instanceCount, int firstInstance, int drawCount, + CallbackInfo ci) { + RedundantBinds.drew(drawCount); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/entities/CullTarget.java b/src/main/java/it/cosimo/vesuvio/entities/CullTarget.java new file mode 100644 index 0000000..d1fefe7 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/entities/CullTarget.java @@ -0,0 +1,11 @@ +package it.cosimo.vesuvio.entities; + +/** + * Added to {@code Entity} and {@code BlockEntity} by mixins: the last occlusion result for this object, written by + * the cull worker thread and read by the render thread. Packed as {@code (passNumber << 1) | hidden}, 0 = never tested. + */ +public interface CullTarget { + long vesuvio$cullStamp(); + + void vesuvio$setCullStamp(long stamp); +} diff --git a/src/main/java/it/cosimo/vesuvio/entities/CullWorker.java b/src/main/java/it/cosimo/vesuvio/entities/CullWorker.java new file mode 100644 index 0000000..7feeab8 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/entities/CullWorker.java @@ -0,0 +1,187 @@ +package it.cosimo.vesuvio.entities; + +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicReferenceArray; +import java.util.concurrent.locks.LockSupport; + +import it.cosimo.vesuvio.Vesuvio; +import net.minecraft.client.multiplayer.ClientLevel; + +/** + * The background thread that runs the occlusion tests. The render thread fills a {@link Batch} each frame (the + * boxes of the entities / block entities that passed vanilla's frustum test, copied, plus the camera position) and + * drops it in a one-slot mailbox; the worker always takes the newest batch (older ones are recycled unseen), tests + * every box and writes the result into the object ({@link CullTarget}), tagged with its pass number. The render + * thread never waits: it uses whatever results are there and treats anything missing or stale as visible. + * + *

No allocation in steady state: batches are recycled through a small pool and only grow. + */ +final class CullWorker implements Runnable { + /** Box tests per pass, at most this often (keeps one core from spinning at 500 fps). */ + private static final long MIN_PASS_NANOS = 4_000_000L; + static final int RING = 16; + + static final class Batch { + Object[] targets = new Object[512]; + double[] boxes = new double[512 * 6]; + int count; + ClientLevel level; + double camX; + double camY; + double camZ; + long createdNanos; + + void add(CullTarget target, double minX, double minY, double minZ, double maxX, double maxY, double maxZ) { + if (count == targets.length) { + targets = Arrays.copyOf(targets, count * 2); + boxes = Arrays.copyOf(boxes, count * 12); + } + targets[count] = target; + int b = count * 6; + boxes[b] = minX; + boxes[b + 1] = minY; + boxes[b + 2] = minZ; + boxes[b + 3] = maxX; + boxes[b + 4] = maxY; + boxes[b + 5] = maxZ; + count++; + } + + void clear() { + Arrays.fill(targets, 0, count, null); + count = 0; + level = null; + } + } + + private static final AtomicReference MAILBOX = new AtomicReference<>(); + private static final AtomicReferenceArray POOL = new AtomicReferenceArray<>(4); + private static volatile Thread thread; + private static volatile boolean waiting; + + /** Per pass: camera position and the time its batch was made, for the render thread's freshness check. */ + static final double[] RING_CAM = new double[RING * 3]; + static final long[] RING_TIME = new long[RING]; + /** Number of the last finished pass (0 = none yet). Written after the ring entry, so reading it first is safe. */ + static volatile long latestPass; + static volatile long totalTests; + static volatile long totalRays; + static volatile int lastPassMicros; + + private final OcclusionTest test = new OcclusionTest(); + + private CullWorker() { + } + + static void ensureStarted() { + if (thread == null) { + synchronized (CullWorker.class) { + if (thread == null) { + Thread t = new Thread(new CullWorker(), "Vesuvio occlusion culling"); + t.setDaemon(true); + t.setPriority(Math.max(Thread.MIN_PRIORITY, Thread.NORM_PRIORITY - 1)); + t.start(); + thread = t; + } + } + } + } + + /** Render thread: an empty batch to fill this frame. */ + static Batch acquire() { + for (int i = 0; i < POOL.length(); i++) { + Batch b = POOL.getAndSet(i, null); + if (b != null) { + return b; + } + } + return new Batch(); + } + + /** Render thread: hand a filled batch to the worker (replacing one it has not started yet, which is recycled). */ + static void publish(Batch batch) { + Batch stale = MAILBOX.getAndSet(batch); + if (stale != null) { + stale.clear(); + release(stale); + } + Thread t = thread; + if (t != null && waiting) { + LockSupport.unpark(t); + } + } + + private static void release(Batch batch) { + for (int i = 0; i < POOL.length(); i++) { + if (POOL.compareAndSet(i, null, batch)) { + return; + } + } + // Pool full (only after a race): let it be collected. + } + + @Override + public void run() { + long pass = 0; + while (true) { + Batch batch = MAILBOX.getAndSet(null); + if (batch == null) { + waiting = true; + if (MAILBOX.get() == null) { + LockSupport.parkNanos(this, 50_000_000L); + } + waiting = false; + continue; + } + long start = System.nanoTime(); + try { + pass++; + runPass(batch, pass); + } catch (Throwable t) { + // Never let the thread die; a failed pass just leaves older results, which go stale -> visible. + Vesuvio.LOGGER.debug("Vesuvio: occlusion pass failed", t); + } finally { + test.end(); + batch.clear(); + release(batch); + } + long spent = System.nanoTime() - start; + lastPassMicros = (int) Math.min(Integer.MAX_VALUE, spent / 1000L); + if (spent < MIN_PASS_NANOS) { + LockSupport.parkNanos(this, MIN_PASS_NANOS - spent); + } + } + } + + private void runPass(Batch batch, long pass) { + ClientLevel level = batch.level; + if (level == null) { + return; + } + test.begin(level, batch.camX, batch.camY, batch.camZ); + long hiddenTag = (pass << 1) | 1L; + long visibleTag = pass << 1; + double[] boxes = batch.boxes; + Object[] targets = batch.targets; + int n = batch.count; + for (int i = 0; i < n; i++) { + int b = i * 6; + boolean visible; + try { + visible = test.boxVisible(boxes[b], boxes[b + 1], boxes[b + 2], boxes[b + 3], boxes[b + 4], boxes[b + 5]); + } catch (RuntimeException e) { + visible = true; + } + ((CullTarget) targets[i]).vesuvio$setCullStamp(visible ? visibleTag : hiddenTag); + } + int slot = (int) (pass & (RING - 1)); + RING_CAM[slot * 3] = batch.camX; + RING_CAM[slot * 3 + 1] = batch.camY; + RING_CAM[slot * 3 + 2] = batch.camZ; + RING_TIME[slot] = batch.createdNanos; + totalTests += n; + totalRays = test.rays; + latestPass = pass; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/entities/Culling.java b/src/main/java/it/cosimo/vesuvio/entities/Culling.java new file mode 100644 index 0000000..4ce5da5 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/entities/Culling.java @@ -0,0 +1,460 @@ +package it.cosimo.vesuvio.entities; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.entities.mixin.EntityRendererInvoker; +import it.cosimo.vesuvio.entities.mixin.FrustumInvoker; +import it.cosimo.vesuvio.ui.VesuvioStats; +import net.minecraft.client.Camera; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.SectionOcclusionGraph; +import net.minecraft.client.renderer.ViewArea; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.client.renderer.culling.Frustum; +import net.minecraft.client.renderer.entity.EntityRenderDispatcher; +import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Display; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.Leashable; +import net.minecraft.world.entity.boss.enderdragon.EndCrystal; +import net.minecraft.world.entity.monster.Guardian; +import net.minecraft.world.entity.monster.Shulker; +import net.minecraft.world.level.block.entity.BannerBlockEntity; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.piston.PistonMovingBlockEntity; +import net.minecraft.world.phys.AABB; + +/** + * Skips drawing entities, block entities and particles the camera cannot see. Render-thread side: called from the + * mixins in {@code LevelExtractor} (entity / block entity extraction) and {@code QuadParticleGroup} (particle + * extraction); the ray casts run on {@link CullWorker}. + * + *

    + *
  • Entities and block entities that pass vanilla's own checks are queued for an occlusion test each frame; one + * whose latest fresh result says "hidden" is not extracted (no render state, no submit, no draw). Unknown, stale + * or failed results mean visible, so the worst case is a hidden object drawn anyway, or one that comes into view + * a frame or two late.
  • + *
  • Block entities also get a per-object frustum test (vanilla only tests their 16x16x16 section).
  • + *
  • Particles in a section that vanilla's own section occlusion graph cannot reach (the same test that hides + * cave terrain) are not extracted.
  • + *
+ */ +public final class Culling { + public static final VesuvioConfig.Toggle ENTITIES = VesuvioConfig.toggle("entities", "entityCulling", true, false); + public static final VesuvioConfig.Toggle BLOCK_ENTITIES = VesuvioConfig.toggle("entities", "blockEntityCulling", true, false); + public static final VesuvioConfig.Toggle PARTICLES = VesuvioConfig.toggle("entities", "particleCulling", true, false); + + // ---- Counters (render thread writes, settings screen / benchmark reads) ---- + /** Entities that passed vanilla's checks but were hidden by occlusion culling, last frame. */ + public static int entitiesCulledLastFrame; + /** Entities sent to the occlusion test last frame (vanilla-visible candidates). */ + public static int entitiesTestedLastFrame; + /** Block entities skipped last frame (occlusion + per-object frustum). */ + public static int blockEntitiesCulledLastFrame; + /** Of those, skipped by the per-object frustum test alone. */ + public static int blockEntitiesFrustumCulledLastFrame; + /** Block entities sent to the occlusion test last frame. */ + public static int blockEntitiesTestedLastFrame; + /** Particles inside the view frustum but in an unreachable section, skipped last frame. */ + public static int particlesCulledLastFrame; + /** Occlusion tests (one per box) done by the worker per second, and ray casts per second. */ + public static int testsPerSecond; + public static int raysPerSecond; + /** Duration of the worker's last pass, microseconds. */ + public static int lastPassMicros; + + /** A result is used only if its pass is this recent and was made from a camera this close to the current one. */ + private static final long MAX_RESULT_AGE_NANOS = 150_000_000L; + private static final double MAX_RESULT_CAMERA_MOVE = 1.0; + /** Never cull an entity box bigger than this on any axis (huge custom models, broken boxes). */ + private static final double MAX_ENTITY_BOX = 32.0; + + /** Half-size of the box around the camera that must be free of opaque blocks (> the 0.05 near plane). */ + private static final double NEAR = 0.15; + private static final BlockPos.MutableBlockPos SCRATCH_POS = new BlockPos.MutableBlockPos(); + + private static boolean entitiesOn; + private static boolean blockEntityFrustumOn; + private static boolean blockEntityOcclusionOn; + private static boolean particlesOn; + private static long minValidPass = Long.MAX_VALUE; + private static CullWorker.Batch batch; + private static Frustum frustum; + private static Minecraft minecraft; + private static Entity cameraEntity; + private static double camX; + private static double camY; + private static double camZ; + private static double camSpeedMargin; + private static double lastCamX = Double.NaN; + private static double lastCamY; + private static double lastCamZ; + + private static int entityCulled; + private static int entityTested; + private static int blockEntityCulled; + private static int blockEntityFrustumCulled; + private static int blockEntityTested; + private static int particleCulled; + + private static ViewArea viewArea; + private static SectionOcclusionGraph graph; + private static long lastParticleSection = Long.MIN_VALUE; + private static boolean lastParticleSectionHidden; + + private static long statsStart; + private static long statsTests; + private static long statsRays; + + private Culling() { + } + + /** Called from Vesuvio.onInitializeClient: makes the settings and registers the counters. */ + public static void init() { + // F3, the settings screen's box, the stats log and the benchmark's counter table. + VesuvioStats.register("entities culled", () -> entitiesCulledLastFrame); + VesuvioStats.register("block entities culled", () -> blockEntitiesCulledLastFrame); + VesuvioStats.register("particles culled", () -> particlesCulledLastFrame); + } + + // ------------------------------------------------------------------ frame + + /** LevelExtractor.extract, HEAD (render thread). */ + public static void beginFrame(Minecraft mc, Camera camera, ClientLevel level, LevelRenderer levelRenderer) { + if (batch != null) { + batch.clear(); + } + entityCulled = 0; + entityTested = 0; + blockEntityCulled = 0; + blockEntityFrustumCulled = 0; + blockEntityTested = 0; + particleCulled = 0; + lastParticleSection = Long.MIN_VALUE; + + boolean anyOn = ENTITIES.on() || BLOCK_ENTITIES.on() || PARTICLES.on(); + if (!anyOn || level == null || camera == null || !camera.isInitialized()) { + entitiesOn = blockEntityFrustumOn = blockEntityOcclusionOn = particlesOn = false; + return; + } + minecraft = mc; + cameraEntity = camera.entity(); + frustum = camera.getCullFrustum(); + camX = camera.position().x; + camY = camera.position().y; + camZ = camera.position().z; + double moved = Double.isNaN(lastCamX) ? 0.0 + : Math.sqrt((camX - lastCamX) * (camX - lastCamX) + (camY - lastCamY) * (camY - lastCamY) + + (camZ - lastCamZ) * (camZ - lastCamZ)); + lastCamX = camX; + lastCamY = camY; + lastCamZ = camZ; + // Results lag a frame or two: grow the tested boxes by about two frames of camera movement. + camSpeedMargin = Math.min(1.0, moved * 2.0); + + // Same conditions under which vanilla stops trusting walls: the F3 culling toggle off, a frozen debug + // frustum, or the camera inside an opaque block (spectator no-clip). Also off while the camera is so close + // to an opaque block that the near plane may cut into it and show what is behind (spectators gliding + // through walls). + boolean occlusionAllowed = mc.smartCull && camera.getCapturedFrustum() == null + && !cameraNearOpaque(level); + + entitiesOn = ENTITIES.on() && occlusionAllowed; + blockEntityFrustumOn = BLOCK_ENTITIES.on() && frustum != null; + blockEntityOcclusionOn = BLOCK_ENTITIES.on() && occlusionAllowed; + particlesOn = PARTICLES.on() && occlusionAllowed && initParticleGraph(levelRenderer, camera.blockPosition()); + + if (entitiesOn || blockEntityOcclusionOn) { + CullWorker.ensureStarted(); + minValidPass = computeMinValidPass(); + if (batch == null) { + batch = CullWorker.acquire(); + } + batch.level = level; + batch.camX = camX; + batch.camY = camY; + batch.camZ = camZ; + batch.createdNanos = System.nanoTime(); + } + } + + private static boolean cameraNearOpaque(ClientLevel level) { + for (int i = 0; i < 8; i++) { + SCRATCH_POS.set(Mth.floor(camX + ((i & 1) == 0 ? -NEAR : NEAR)), Mth.floor(camY + ((i & 2) == 0 ? -NEAR : NEAR)), + Mth.floor(camZ + ((i & 4) == 0 ? -NEAR : NEAR))); + if (level.getBlockState(SCRATCH_POS).isSolidRender()) { + return true; + } + } + return false; + } + + /** LevelExtractor.extract, RETURN (render thread). */ + public static void endFrame() { + entitiesCulledLastFrame = entityCulled; + entitiesTestedLastFrame = entityTested; + blockEntitiesCulledLastFrame = blockEntityCulled; + blockEntitiesFrustumCulledLastFrame = blockEntityFrustumCulled; + blockEntitiesTestedLastFrame = blockEntityTested; + particlesCulledLastFrame = particleCulled; + if (batch != null && batch.level != null) { + if (batch.count > 0) { + CullWorker.publish(batch); + batch = null; + } else { + batch.clear(); + } + } + // The hooks act only inside extract(): with the frame state cleared below, they must not act after it. + entitiesOn = blockEntityFrustumOn = blockEntityOcclusionOn = particlesOn = false; + minecraft = null; + cameraEntity = null; + frustum = null; + viewArea = null; + graph = null; + long now = System.nanoTime(); + if (now - statsStart >= 1_000_000_000L) { + long tests = CullWorker.totalTests; + long rays = CullWorker.totalRays; + double seconds = (now - statsStart) / 1.0E9; + if (statsStart != 0) { + testsPerSecond = (int) ((tests - statsTests) / seconds); + raysPerSecond = (int) ((rays - statsRays) / seconds); + } + statsStart = now; + statsTests = tests; + statsRays = rays; + lastPassMicros = CullWorker.lastPassMicros; + } + } + + /** Oldest pass whose results are still trusted: recent, and made from (almost) the current camera position. */ + private static long computeMinValidPass() { + long latest = CullWorker.latestPass; + long now = System.nanoTime(); + long min = Long.MAX_VALUE; + // Look back at most half the ring, so the slot the worker may be writing right now is never read. + for (long p = latest; p > 0 && p > latest - CullWorker.RING / 2; p--) { + int slot = (int) (p & (CullWorker.RING - 1)); + double dx = CullWorker.RING_CAM[slot * 3] - camX; + double dy = CullWorker.RING_CAM[slot * 3 + 1] - camY; + double dz = CullWorker.RING_CAM[slot * 3 + 2] - camZ; + if (now - CullWorker.RING_TIME[slot] > MAX_RESULT_AGE_NANOS + || dx * dx + dy * dy + dz * dz > MAX_RESULT_CAMERA_MOVE * MAX_RESULT_CAMERA_MOVE) { + break; + } + min = p; + } + return min; + } + + private static boolean hiddenByLastResult(CullTarget target) { + long stamp = target.vesuvio$cullStamp(); + return (stamp & 1L) != 0 && (stamp >>> 1) >= minValidPass; + } + + // ------------------------------------------------------------------ entities + + /** + * For an entity vanilla is about to extract (it passed frustum and distance checks): queue its occlusion test and + * say whether the last fresh result hides it. + */ + public static boolean entityHidden(Entity entity, float partialTicks, EntityRenderDispatcher dispatcher) { + if (!entitiesOn || batch == null || neverCull(entity)) { + return false; + } + EntityRendererInvoker renderer = (EntityRendererInvoker) dispatcher.getRenderer(entity); + if (renderer == null || !renderer.vesuvio$affectedByCulling(entity)) { + return false; + } + AABB box = renderer.vesuvio$getBoundingBoxForCulling(entity, partialTicks); + if (box.hasNaN() || box.getSize() == 0.0) { + return false; + } + if (box.getXsize() > MAX_ENTITY_BOX || box.getYsize() > MAX_ENTITY_BOX || box.getZsize() > MAX_ENTITY_BOX) { + return false; + } + // Vanilla's frustum margin (0.5), plus how far the entity and the camera move in about two frames. + double motion = Math.abs(entity.getX() - entity.xOld) + Math.abs(entity.getY() - entity.yOld) + + Math.abs(entity.getZ() - entity.zOld); + double m = 0.5 + Math.min(2.0, motion * 2.0) + camSpeedMargin; + // A name tag drawn above the box (only when not see-through; see-through ones are never culled). + double top = entity.shouldShowName() || entity.hasCustomName() ? 0.75 : 0.0; + batch.add((CullTarget) entity, box.minX - m, box.minY - m, box.minZ - m, box.maxX + m, box.maxY + m + top, + box.maxZ + m); + entityTested++; + if (hiddenByLastResult((CullTarget) entity)) { + entityCulled++; + return true; + } + return false; + } + + /** Entities whose look reaches past their box or through walls, or that the player is attached to. */ + private static boolean neverCull(Entity entity) { + Minecraft mc = minecraft; + if (entity == cameraEntity || entity instanceof LocalPlayer || entity == mc.crosshairPickEntity) { + return true; + } + // Glowing outline (and spectator player outlines) are drawn through walls. + if (mc.shouldEntityAppearGlowing(entity)) { + return true; + } + // Name tags are drawn see-through unless the entity is sneaking (players always show a name). + if (entity.shouldShowName() && !entity.isDiscrete()) { + return true; + } + LocalPlayer player = mc.player; + if (player != null && (entity.hasIndirectPassenger(player) || player.hasIndirectPassenger(entity))) { + return true; + } + // The lead is drawn from the leashed entity to its holder, maybe far outside the box. + if (entity instanceof Leashable leashable && leashable.getLeashHolder() != null) { + return true; + } + // Beams drawn from the entity to a target outside its box; shulker peek animation. + if (entity instanceof EndCrystal crystal && crystal.getBeamTarget() != null) { + return true; + } + if (entity instanceof Guardian guardian && guardian.hasActiveAttackTarget()) { + return true; + } + if (entity instanceof Shulker) { + return true; + } + // Text displays can be marked see-through. + if (entity instanceof Display.TextDisplay text) { + Display.TextDisplay.TextRenderState state = text.textRenderState(); + if (state == null || (state.flags() & Display.TextDisplay.FLAG_SEE_THROUGH) != 0) { + return true; + } + } + return false; + } + + // ------------------------------------------------------------------ block entities + + /** + * For a block entity in a visible section that vanilla is about to extract: true to skip it (outside the frustum, + * or hidden by the last fresh occlusion result). + */ + public static boolean blockEntityHidden(BlockEntity blockEntity, BlockEntityRenderDispatcher dispatcher) { + if (!blockEntityFrustumOn) { + return false; + } + BlockEntityRenderer renderer = dispatcher.getRenderer(blockEntity); + if (renderer == null) { + return false; + } + // Beacon beams, end gateways, structure blocks: drawn far outside their block. (Those that say so are drawn + // from the "globally rendered" list, which is never culled; a long view distance is the other sign.) + if (renderer.shouldRenderOffScreen() || renderer.getViewDistance() > 64) { + return false; + } + BlockPos pos = blockEntity.getBlockPos(); + double x = pos.getX(); + double y = pos.getY(); + double z = pos.getZ(); + // Vanilla's own distance check comes after us; don't spend tests on what it will drop anyway. + double vd = renderer.getViewDistance(); + double cx = x + 0.5 - camX; + double cy = y + 0.5 - camY; + double cz = z + 0.5 - camZ; + if (cx * cx + cy * cy + cz * cz >= vd * vd) { + return false; + } + // Box: the block plus room for what vanilla renderers draw outside it (shulker lids, enchanting book, + // skulls, decorated pot wobble...). Banners hang a block below / stand two blocks high; moving piston + // blocks are up to a block away. Modded renderers get a wider margin. + double m = 0.5; + double down = 0.0; + double up = 0.0; + if (blockEntity instanceof BannerBlockEntity) { + down = 1.0; + up = 1.5; + } else if (blockEntity instanceof PistonMovingBlockEntity) { + m = 1.5; + } else if (!renderer.getClass().getName().startsWith("net.minecraft.")) { + m = 1.5; + } + double minX = x - m; + double minY = y - m - down; + double minZ = z - m; + double maxX = x + 1 + m; + double maxY = y + 1 + m + up; + double maxZ = z + 1 + m; + int intersection = ((FrustumInvoker) frustum).vesuvio$cubeInFrustum(minX, minY, minZ, maxX, maxY, maxZ); + if (intersection != -2 && intersection != -1) { + blockEntityCulled++; + blockEntityFrustumCulled++; + return true; + } + if (!blockEntityOcclusionOn || batch == null) { + return false; + } + double c = camSpeedMargin; + batch.add((CullTarget) blockEntity, minX - c, minY - c, minZ - c, maxX + c, maxY + c, maxZ + c); + blockEntityTested++; + if (hiddenByLastResult((CullTarget) blockEntity)) { + blockEntityCulled++; + return true; + } + return false; + } + + // ------------------------------------------------------------------ particles + + private static boolean initParticleGraph(LevelRenderer levelRenderer, BlockPos cameraPos) { + ViewArea area = levelRenderer.viewArea(); + SectionOcclusionGraph g = levelRenderer.sectionOcclusionGraph(); + if (area == null || g == null) { + return false; + } + try { + // Make sure the graph exists (it is built asynchronously after a level loads). + SectionRenderDispatcher.RenderSection cameraSection = area.getRenderSectionAt(cameraPos); + if (cameraSection == null || g.getNode(cameraSection) == null) { + return false; + } + } catch (RuntimeException e) { + return false; + } + viewArea = area; + graph = g; + return true; + } + + /** + * For a particle inside the view frustum: true if its section is one vanilla's section occlusion graph cannot + * reach from the camera (so its terrain is not drawn either). + */ + public static boolean particleHidden(double x, double y, double z) { + if (!particlesOn) { + return false; + } + int sx = SectionPos.blockToSectionCoord(Mth.floor(x)); + int sy = SectionPos.blockToSectionCoord(Mth.floor(y)); + int sz = SectionPos.blockToSectionCoord(Mth.floor(z)); + long key = SectionPos.asLong(sx, sy, sz); + boolean hidden; + if (key == lastParticleSection) { + hidden = lastParticleSectionHidden; + } else { + SectionRenderDispatcher.RenderSection section = viewArea.getSections().getValue(sx, sy, sz); + // Outside the loaded area / build height: keep vanilla's behaviour. + hidden = section != null && graph.getNode(section) == null; + lastParticleSection = key; + lastParticleSectionHidden = hidden; + } + if (hidden) { + particleCulled++; + } + return hidden; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/entities/OcclusionTest.java b/src/main/java/it/cosimo/vesuvio/entities/OcclusionTest.java new file mode 100644 index 0000000..67433d6 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/entities/OcclusionTest.java @@ -0,0 +1,289 @@ +package it.cosimo.vesuvio.entities; + +import java.util.Arrays; + +import net.minecraft.client.multiplayer.ClientChunkCache; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.status.ChunkStatus; + +/** + * "Can the camera see any part of this box?" through the block grid, for the cull worker thread only (one instance, + * not thread-safe). Only full opaque, visible blocks ({@code BlockState.isSolidRender()}: stone, dirt, planks... + * not glass, leaves, slabs, doors, water, barriers) stop a ray; unloaded chunks and the world outside the build + * height count as air. + * + *

A box is visible when a straight segment from the camera to some point of it crosses only non-opaque voxels + * (3D DDA voxel walk, the camera's own voxel ignored). Points tried: the box centre, then a grid (spacing at most + * {@value #STEP} blocks) on each box face turned towards the camera. Rays are walked from the camera outwards, so a + * wall next to the camera stops them after a few voxels. Opacity of the 64x64x64 voxels around the camera is cached + * for one pass. + * + *

Same approach as LogisticsCraft's OcclusionCulling (MIT, used by EntityCulling): visibility by ray casts from + * the camera to sample points on the faces of the box that face it. This is an independent implementation, no code + * copied. + * + *

Reads of the client level from this thread: {@code ClientChunkCache} keeps chunks in an AtomicReferenceArray + * and a volatile storage reference, and {@code PalettedContainer.get} reads its volatile data once, so a read is + * at worst one update stale. Anything that still goes wrong (a storage swap during a render distance change) throws, + * and the caller then treats the box as visible. + */ +final class OcclusionTest { + /** Largest spacing of the sample grid on a box face, in blocks. */ + static final double STEP = 0.5; + /** At most this many grid cells per face side (big boxes get a coarser grid). */ + private static final int MAX_CELLS = 8; + /** Boxes further than this are not tested (reported visible). */ + private static final double MAX_DISTANCE = 256.0; + private static final double EPS = 1.0E-3; + + private static final int SIZE = 64; + private static final int HALF = SIZE / 2; + /** 0 = not looked up this pass, 1 = clear, 2 = opaque. */ + private final byte[] cache = new byte[SIZE * SIZE * SIZE]; + private int originX; + private int originY; + private int originZ; + + private ClientChunkCache chunks; + private int minY; + private int maxY; + private int minSectionY; + + private int sectionX; + private int sectionY; + private int sectionZ; + private boolean sectionKnown; + private LevelChunkSection section; + + private double camX; + private double camY; + private double camZ; + private int camVoxelX; + private int camVoxelY; + private int camVoxelZ; + + /** Rays cast since start (single writer: the worker). */ + long rays; + + void begin(ClientLevel level, double x, double y, double z) { + this.chunks = level.getChunkSource(); + this.minY = level.getMinY(); + this.maxY = level.getMaxY(); + this.minSectionY = level.getMinSectionY(); + this.camX = x; + this.camY = y; + this.camZ = z; + this.camVoxelX = floor(x); + this.camVoxelY = floor(y); + this.camVoxelZ = floor(z); + this.originX = camVoxelX - HALF; + this.originY = camVoxelY - HALF; + this.originZ = camVoxelZ - HALF; + Arrays.fill(cache, (byte) 0); + sectionKnown = false; + section = null; + } + + /** Drop level references between passes so an unloaded world can be collected. */ + void end() { + chunks = null; + section = null; + sectionKnown = false; + } + + boolean boxVisible(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) { + if (camX >= minX && camX <= maxX && camY >= minY && camY <= maxY && camZ >= minZ && camZ <= maxZ) { + return true; + } + double ccx = (minX + maxX) * 0.5; + double ccy = (minY + maxY) * 0.5; + double ccz = (minZ + maxZ) * 0.5; + double dx = ccx - camX; + double dy = ccy - camY; + double dz = ccz - camZ; + if (dx * dx + dy * dy + dz * dz > MAX_DISTANCE * MAX_DISTANCE) { + return true; + } + if (ray(ccx, ccy, ccz)) { + return true; + } + // X faces: grid over (y, z) + if (camX < minX || camX > maxX) { + double fx = camX < minX ? minX + EPS : maxX - EPS; + if (faceX(fx, minY, minZ, maxY, maxZ)) { + return true; + } + } + if (camY < minY || camY > maxY) { + double fy = camY < minY ? minY + EPS : maxY - EPS; + if (faceY(fy, minX, minZ, maxX, maxZ)) { + return true; + } + } + if (camZ < minZ || camZ > maxZ) { + double fz = camZ < minZ ? minZ + EPS : maxZ - EPS; + if (faceZ(fz, minX, minY, maxX, maxY)) { + return true; + } + } + return false; + } + + private static int cells(double size) { + return Math.max(1, Math.min(MAX_CELLS, (int) Math.ceil(size / STEP))); + } + + private boolean faceX(double x, double minY, double minZ, double maxY, double maxZ) { + int nu = cells(maxY - minY); + int nv = cells(maxZ - minZ); + double y0 = minY + EPS; + double z0 = minZ + EPS; + double su = (maxY - minY - 2 * EPS) / nu; + double sv = (maxZ - minZ - 2 * EPS) / nv; + for (int i = 0; i <= nu; i++) { + for (int j = 0; j <= nv; j++) { + if (ray(x, y0 + i * su, z0 + j * sv)) { + return true; + } + } + } + return false; + } + + private boolean faceY(double y, double minX, double minZ, double maxX, double maxZ) { + int nu = cells(maxX - minX); + int nv = cells(maxZ - minZ); + double x0 = minX + EPS; + double z0 = minZ + EPS; + double su = (maxX - minX - 2 * EPS) / nu; + double sv = (maxZ - minZ - 2 * EPS) / nv; + for (int i = 0; i <= nu; i++) { + for (int j = 0; j <= nv; j++) { + if (ray(x0 + i * su, y, z0 + j * sv)) { + return true; + } + } + } + return false; + } + + private boolean faceZ(double z, double minX, double minY, double maxX, double maxY) { + int nu = cells(maxX - minX); + int nv = cells(maxY - minY); + double x0 = minX + EPS; + double y0 = minY + EPS; + double su = (maxX - minX - 2 * EPS) / nu; + double sv = (maxY - minY - 2 * EPS) / nv; + for (int i = 0; i <= nu; i++) { + for (int j = 0; j <= nv; j++) { + if (ray(x0 + i * su, y0 + j * sv, z)) { + return true; + } + } + } + return false; + } + + /** True if the segment camera -> (ex, ey, ez) crosses no opaque voxel (camera voxel excluded, end voxel included). */ + private boolean ray(double ex, double ey, double ez) { + rays++; + double dx = ex - camX; + double dy = ey - camY; + double dz = ez - camZ; + int x = camVoxelX; + int y = camVoxelY; + int z = camVoxelZ; + int tx = floor(ex); + int ty = floor(ey); + int tz = floor(ez); + int stepX = Integer.signum(tx - x); + int stepY = Integer.signum(ty - y); + int stepZ = Integer.signum(tz - z); + // t runs from 0 (camera) to 1 (end point); tMax = t at the next voxel boundary on that axis. + double tDeltaX = dx != 0 ? Math.abs(1.0 / dx) : Double.POSITIVE_INFINITY; + double tDeltaY = dy != 0 ? Math.abs(1.0 / dy) : Double.POSITIVE_INFINITY; + double tDeltaZ = dz != 0 ? Math.abs(1.0 / dz) : Double.POSITIVE_INFINITY; + double tMaxX = stepX > 0 ? (x + 1 - camX) * tDeltaX : stepX < 0 ? (camX - x) * tDeltaX : Double.POSITIVE_INFINITY; + double tMaxY = stepY > 0 ? (y + 1 - camY) * tDeltaY : stepY < 0 ? (camY - y) * tDeltaY : Double.POSITIVE_INFINITY; + double tMaxZ = stepZ > 0 ? (z + 1 - camZ) * tDeltaZ : stepZ < 0 ? (camZ - z) * tDeltaZ : Double.POSITIVE_INFINITY; + int steps = Math.abs(tx - x) + Math.abs(ty - y) + Math.abs(tz - z); + while (steps-- > 0) { + // Step the axis whose boundary comes first, never past the end voxel on any axis. + boolean canX = x != tx; + boolean canY = y != ty; + boolean canZ = z != tz; + if (canX && (!canY || tMaxX <= tMaxY) && (!canZ || tMaxX <= tMaxZ)) { + x += stepX; + tMaxX += tDeltaX; + } else if (canY && (!canZ || tMaxY <= tMaxZ)) { + y += stepY; + tMaxY += tDeltaY; + } else { + z += stepZ; + tMaxZ += tDeltaZ; + } + if (opaque(x, y, z)) { + return false; + } + } + return true; + } + + private boolean opaque(int x, int y, int z) { + int lx = x - originX; + int ly = y - originY; + int lz = z - originZ; + if (lx >= 0 && ly >= 0 && lz >= 0 && lx < SIZE && ly < SIZE && lz < SIZE) { + int index = (lx * SIZE + ly) * SIZE + lz; + byte known = cache[index]; + if (known != 0) { + return known == 2; + } + boolean o = lookup(x, y, z); + cache[index] = o ? (byte) 2 : (byte) 1; + return o; + } + return lookup(x, y, z); + } + + private boolean lookup(int x, int y, int z) { + if (y < minY || y > maxY) { + return false; + } + int sx = x >> 4; + int sy = y >> 4; + int sz = z >> 4; + if (!sectionKnown || sx != sectionX || sy != sectionY || sz != sectionZ) { + sectionKnown = true; + sectionX = sx; + sectionY = sy; + sectionZ = sz; + section = null; + LevelChunk chunk = chunks.getChunk(sx, sz, ChunkStatus.FULL, false); + if (chunk != null) { + LevelChunkSection[] sections = chunk.getSections(); + int i = sy - minSectionY; + if (i >= 0 && i < sections.length) { + LevelChunkSection s = sections[i]; + if (s != null && !s.hasOnlyAir()) { + section = s; + } + } + } + } + if (section == null) { + return false; + } + BlockState state = section.getBlockState(x & 15, y & 15, z & 15); + return state.isSolidRender() && state.getRenderShape() != RenderShape.INVISIBLE; + } + + private static int floor(double v) { + int i = (int) v; + return v < i ? i - 1 : i; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/entities/mixin/BlockEntityCullStateMixin.java b/src/main/java/it/cosimo/vesuvio/entities/mixin/BlockEntityCullStateMixin.java new file mode 100644 index 0000000..7cf7c4f --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/entities/mixin/BlockEntityCullStateMixin.java @@ -0,0 +1,24 @@ +package it.cosimo.vesuvio.entities.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import it.cosimo.vesuvio.entities.CullTarget; +import net.minecraft.world.level.block.entity.BlockEntity; + +/** Holds the block entity's last occlusion result (written by the cull worker, read by the render thread). */ +@Mixin(BlockEntity.class) +public abstract class BlockEntityCullStateMixin implements CullTarget { + @Unique + private volatile long vesuvio$cull; + + @Override + public long vesuvio$cullStamp() { + return vesuvio$cull; + } + + @Override + public void vesuvio$setCullStamp(long stamp) { + vesuvio$cull = stamp; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/entities/mixin/EntityCullStateMixin.java b/src/main/java/it/cosimo/vesuvio/entities/mixin/EntityCullStateMixin.java new file mode 100644 index 0000000..c6addbd --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/entities/mixin/EntityCullStateMixin.java @@ -0,0 +1,24 @@ +package it.cosimo.vesuvio.entities.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import it.cosimo.vesuvio.entities.CullTarget; +import net.minecraft.world.entity.Entity; + +/** Holds the entity's last occlusion result (written by the cull worker, read by the render thread). */ +@Mixin(Entity.class) +public abstract class EntityCullStateMixin implements CullTarget { + @Unique + private volatile long vesuvio$cull; + + @Override + public long vesuvio$cullStamp() { + return vesuvio$cull; + } + + @Override + public void vesuvio$setCullStamp(long stamp) { + vesuvio$cull = stamp; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/entities/mixin/EntityRendererInvoker.java b/src/main/java/it/cosimo/vesuvio/entities/mixin/EntityRendererInvoker.java new file mode 100644 index 0000000..4087fcf --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/entities/mixin/EntityRendererInvoker.java @@ -0,0 +1,18 @@ +package it.cosimo.vesuvio.entities.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.AABB; + +/** The box vanilla frustum-culls an entity with (renderer-specific: minecarts, tridents, sniffers...). */ +@Mixin(EntityRenderer.class) +public interface EntityRendererInvoker { + @Invoker("getBoundingBoxForCulling") + AABB vesuvio$getBoundingBoxForCulling(Entity entity, float partialTicks); + + @Invoker("affectedByCulling") + boolean vesuvio$affectedByCulling(Entity entity); +} diff --git a/src/main/java/it/cosimo/vesuvio/entities/mixin/FrustumInvoker.java b/src/main/java/it/cosimo/vesuvio/entities/mixin/FrustumInvoker.java new file mode 100644 index 0000000..8e6daec --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/entities/mixin/FrustumInvoker.java @@ -0,0 +1,13 @@ +package it.cosimo.vesuvio.entities.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +import net.minecraft.client.renderer.culling.Frustum; + +/** Frustum test of a box without allocating an AABB (-2 inside, -1 intersecting, anything else outside). */ +@Mixin(Frustum.class) +public interface FrustumInvoker { + @Invoker("cubeInFrustum") + int vesuvio$cubeInFrustum(double minX, double minY, double minZ, double maxX, double maxY, double maxZ); +} diff --git a/src/main/java/it/cosimo/vesuvio/entities/mixin/LevelExtractorMixin.java b/src/main/java/it/cosimo/vesuvio/entities/mixin/LevelExtractorMixin.java new file mode 100644 index 0000000..8dac113 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/entities/mixin/LevelExtractorMixin.java @@ -0,0 +1,75 @@ +package it.cosimo.vesuvio.entities.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.entities.Culling; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderDispatcher; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.extract.LevelExtractor; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.entity.BlockEntity; + +/** + * Entity and block entity occlusion culling, at the point where vanilla picks what to extract for the camera: + * {@code LevelExtractor.extractVisibleEntities} (after vanilla's own {@code isEntityVisible}) and the per-section + * loop of {@code extractVisibleBlockEntities} (the globally rendered list: beacons, gateways, structure blocks, is + * not touched). Etna Shaders' shadow pass draws terrain only and never comes through here. + */ +@Mixin(LevelExtractor.class) +public abstract class LevelExtractorMixin { + @Shadow + @Final + private Minecraft minecraft; + + @Shadow + @Final + private LevelRenderer levelRenderer; + + @Shadow + private ClientLevel level; + + @Inject(method = "extract", at = @At("HEAD")) + private void vesuvio$beginCulling(DeltaTracker deltaTracker, Camera camera, float worldPartialTicks, CallbackInfo ci) { + Culling.beginFrame(minecraft, camera, level, levelRenderer); + } + + @Inject(method = "extract", at = @At("RETURN")) + private void vesuvio$endCulling(DeltaTracker deltaTracker, Camera camera, float worldPartialTicks, CallbackInfo ci) { + Culling.endFrame(); + } + + /** Vanilla's isEntityVisible answer, narrowed; entity and partial tick are the loop's locals it was called with. */ + @ModifyExpressionValue(method = "extractVisibleEntities", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/extract/LevelExtractor;isEntityVisible(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/client/renderer/culling/Frustum;DDDFJ)Z")) + private boolean vesuvio$occlusionCullEntity(boolean visible, @Local Entity entity, @Local float partialTicks) { + return visible && !Culling.entityHidden(entity, partialTicks, levelRenderer.entityRenderDispatcher()); + } + + @WrapOperation(method = "extractVisibleBlockEntities", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/blockentity/BlockEntityRenderDispatcher;tryExtractRenderState(Lnet/minecraft/world/level/block/entity/BlockEntity;FLnet/minecraft/client/renderer/feature/ModelFeatureRenderer$CrumblingOverlay;Z)Lnet/minecraft/client/renderer/blockentity/state/BlockEntityRenderState;", + ordinal = 0)) + private BlockEntityRenderState vesuvio$cullBlockEntity(BlockEntityRenderDispatcher dispatcher, BlockEntity blockEntity, + float partialTicks, ModelFeatureRenderer.CrumblingOverlay breakProgress, boolean isGloballyRendered, + Operation original) { + if (!isGloballyRendered && Culling.blockEntityHidden(blockEntity, dispatcher)) { + return null; + } + return original.call(dispatcher, blockEntity, partialTicks, breakProgress, isGloballyRendered); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/entities/mixin/QuadParticleGroupMixin.java b/src/main/java/it/cosimo/vesuvio/entities/mixin/QuadParticleGroupMixin.java new file mode 100644 index 0000000..d432381 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/entities/mixin/QuadParticleGroupMixin.java @@ -0,0 +1,30 @@ +package it.cosimo.vesuvio.entities.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.culling.mixin.ParticleAccessor; +import it.cosimo.vesuvio.entities.Culling; +import net.minecraft.client.particle.QuadParticleGroup; +import net.minecraft.client.particle.SingleQuadParticle; + +/** + * Vanilla already skips particles outside the view frustum here ({@code Frustum.pointInFrustum}); this adds: skip + * particles in sections vanilla's section occlusion graph can't reach from the camera (behind walls, other caves). + */ +@Mixin(QuadParticleGroup.class) +public abstract class QuadParticleGroupMixin { + /** The frustum test's own arguments are {@code particle.x, particle.y, particle.z}: read back from the particle. */ + @ModifyExpressionValue(method = "extractRenderState", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/culling/Frustum;pointInFrustum(DDD)Z")) + private boolean vesuvio$cullHiddenParticle(boolean inFrustum, @Local SingleQuadParticle particle) { + if (!inFrustum) { + return false; + } + ParticleAccessor pos = (ParticleAccessor) particle; + return !Culling.particleHidden(pos.vesuvio$x(), pos.vesuvio$y(), pos.vesuvio$z()); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/faces/FaceCulling.java b/src/main/java/it/cosimo/vesuvio/faces/FaceCulling.java new file mode 100644 index 0000000..79d6a9c --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/faces/FaceCulling.java @@ -0,0 +1,459 @@ +package it.cosimo.vesuvio.faces; + +import java.util.List; + +import com.mojang.renderpearl.util.PlatformUtil; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.Vesuvio; +import it.cosimo.vesuvio.draws.SubmitTimers; +import it.cosimo.vesuvio.ui.VesuvioStats; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.DynamicGpuData; +import net.minecraft.client.renderer.chunk.ChunkSectionLayer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.phys.Vec3; + +/** + * Per-direction face culling of terrain sections (the idea Sodium made known; this is Vesuvio's own code). + * + *

FaceSorter sorts each opaque section mesh's quads into seven groups when the section is built: the + * six directions a quad on the block grid can face, and everything else. When vanilla's main view + * prepares its terrain draws (LevelRenderer.extractSectionDrawGroups), each opaque section draw is cut to + * the groups that can face the camera: quads facing +X are front faces only when the camera is east of + * their plane, so a section wholly west of the camera has none to draw. The GPU would throw those faces + * away anyway (back-face culling), but only after shading their vertices: skipping them first saves about + * half the vertex work of the terrain pass, and nothing in the picture changes. + * + *

Group order in the buffer: +X +Z +Y unassigned -Z -X -Y. Of the eight ways a section can lie from the + * camera (one side of each axis), this order (one of the best of all 5040) needs 1 draw for 3 of them, 2 + * for 2 and 3 for 3: 2 on average, 1.5 when the camera is above the section, and a single draw for any + * sun or moon position in the shadow pass (light from above, both Z sides). Hidden groups at either end of + * a draw cost nothing to leave out; a hidden run between two visible ones costs a second draw (on the Mac, + * one more Metal draw per section), so it is left out only when it has at least faceCullingMinGap quads, + * otherwise drawn with the rest. + * + *

faceCullingMinGap is how many hidden quads are worth one more draw when the GPU's time is what counts. With + * faceCullingAutoSplit on (default), on macOS that holds only while the GPU is the limit: MoltenVK turns every + * draw into a Metal draw on the CPU (about 1 us each), so while the CPU is the limit a split costs frame time and + * the vertex work it saves on the idle GPU buys nothing; there the effective value is the highest one (no draw is + * ever split, the hidden ends are still left out). Which side is the limit comes from the submit timers + * (SubmitTimers.gpuBound: at least 10% of the frame spent waiting for the GPU, decided once a second). On + * Windows and Linux the drivers draw an indirect batch as one command and the value applies as set. Any split + * choice draws the same picture. + * + *

Only vanilla's own main-view call is cut (LevelRendererFacesMixin wraps the two prepare calls in + * LevelRenderer.render). Any other caller, Etna's shadow pass included, gets whole meshes unless it asks + * for culling along a direction with {@link #cullNextPrepareTowards}. + */ +public final class FaceCulling { + public static final VesuvioConfig.Toggle FACE_CULLING = VesuvioConfig.toggle("rendering", "faceCulling", true, false); + /** A hidden run of quads between two drawn ones is skipped (at the price of one more draw) only from this many quads. */ + public static final VesuvioConfig.Number MIN_GAP = VesuvioConfig.number("rendering", "faceCullingMinGap", 0, 65536, 64, false); + /** On macOS, splits only while the GPU is the limit (see the class comment); faceCullingMinGap as set everywhere else. */ + public static final VesuvioConfig.Toggle AUTO_SPLIT = VesuvioConfig.toggle("rendering", "faceCullingAutoSplit", true, false); + + // Counters for the settings screen and benchmark (render thread). Main view, last frame: + /** Opaque terrain quads left out of the main view's draws. */ + public static int quadsSkippedLastFrame; + /** Opaque terrain quads drawn in the main view (skipped / (skipped + drawn) is the share saved). */ + public static int quadsDrawnLastFrame; + /** Draws added by splitting section draws in two or three (each one indirect command / Metal draw more). */ + public static int drawsAddedLastFrame; + /** Opaque layers of visible sections drawn whole because they were built while face culling was off (being rebuilt). */ + public static int ungroupedSectionsLastFrame; + /** The same for the last prepare that used {@link #cullNextPrepareTowards} (Etna's shadow pass). */ + public static int directionalQuadsSkippedLastPass; + public static int directionalDrawsAddedLastPass; + + /** How far outside a section its grouped planes can be (FaceSorter), plus room for view bobbing. */ + private static final double MARGIN = FaceSorter.PLANE_MARGIN + 0.5; + /** For directional culling: a light this close to edge-on to a facing keeps both sides of it. */ + private static final float EDGE_ON = 1.0E-3F; + private static final int ALL = (1 << FaceSorter.GROUPS) - 1; + private static final int REGROUP_PER_FRAME = 24; + + private static final int NONE = 0; + private static final int MAIN = 1; + private static final int DIRECTIONAL = 2; + + private static final ChunkSectionLayer[] LAYERS = ChunkSectionLayer.values(); + + // Render thread only. + private static boolean mainViewCall; + private static double camX; + private static double camY; + private static double camZ; + private static boolean armed; + private static int armedMask; + private static final boolean[] armedLayers = new boolean[LAYERS.length]; + + private static int mode = NONE; + /** {@link #mainViewKey} as the last main-view prepare used it. */ + private static long lastMainViewKey; + private static int directionalMask; + private static final boolean[] layerOn = new boolean[LAYERS.length]; + private static int minGap; + private static int skipped; + private static int drawn; + private static int added; + private static int ungrouped; + private static final int[] regroup = new int[REGROUP_PER_FRAME * 3]; + private static int regroupCount; + private static boolean regroupFailed; + /** Per layer: 0 not checked yet, 1 its pipelines cull back faces, 2 they don't (never cut). */ + private static final byte[] layerCulls = new byte[LAYERS.length]; + + private FaceCulling() { + } + + /** Called from Vesuvio.onInitializeClient: makes the settings and registers the counters. */ + public static void init() { + // F3, the settings screen's box, the stats log and the benchmark's counter table. + VesuvioStats.register("terrain quads skipped %", FaceCulling::skippedPercent); + VesuvioStats.register("face draws added", () -> drawsAddedLastFrame); + SectionDrawCache.init(); + } + + /** + * The split threshold for this frame: faceCullingMinGap, or on macOS with faceCullingAutoSplit while the CPU is + * the limit, the highest value (no splits). Render thread. + */ + static int effectiveMinGap() { + if (PlatformUtil.IS_MACOS && AUTO_SPLIT.on() && !SubmitTimers.gpuBound()) { + return MIN_GAP.max; + } + return MIN_GAP.get(); + } + + /** Inside vanilla's own main-view prepare call (SectionDrawCache only serves that one). */ + static boolean inMainView() { + return mainViewCall; + } + + /** + * Everything besides the camera that decides how a main-view prepare cuts its draws: 0 with face culling off, + * else on plus the split threshold. Render thread, inside the main-view call. + */ + static long mainViewKey() { + return FACE_CULLING.on() ? (1L << 32) | effectiveMinGap() : 0L; + } + + /** {@link #mainViewKey} as the last main-view prepare computed it. */ + static long lastMainViewKey() { + return lastMainViewKey; + } + + /** + * The main view's camera, reduced to what decides the facing groups shown ({@link #mainMask}): per axis, the + * first section index whose positive-facing group is hidden and the first whose negative-facing group is shown. + * Two cameras with the same six numbers give every section the same mask. False when the camera is not a usable + * number. Render thread, inside the main-view call. + */ + static boolean cameraCell(int[] out) { + out[0] = firstHiddenPositive(camX); + out[1] = firstShownNegative(camX); + out[2] = firstHiddenPositive(camY); + out[3] = firstShownNegative(camY); + out[4] = firstHiddenPositive(camZ); + out[5] = firstShownNegative(camZ); + for (int i = 0; i < 6; i++) { + if (out[i] == Integer.MIN_VALUE) { + return false; + } + } + return true; + } + + /** + * The smallest k for which a section with origin 16k along this axis gets no positive-facing group from + * {@link #mainMask} (the test there, {@code cam - origin > -MARGIN}, is true for small k and false from here on). + */ + private static int firstHiddenPositive(double cam) { + if (!(Math.abs(cam) < 1.0E8)) { + return Integer.MIN_VALUE; + } + int k = (int) Math.ceil((cam + MARGIN) / 16.0); + while (!(cam - (double) (16 * (k - 1)) > -MARGIN)) { + k--; + } + while (cam - (double) (16 * k) > -MARGIN) { + k++; + } + return k; + } + + /** + * The smallest k for which a section with origin 16k gets its negative-facing group ({@code cam - origin < + * 16 + MARGIN} in {@link #mainMask}: false for small k, true from here on). + */ + private static int firstShownNegative(double cam) { + if (!(Math.abs(cam) < 1.0E8)) { + return Integer.MIN_VALUE; + } + int k = (int) Math.floor((cam - 16.0 - MARGIN) / 16.0) + 1; + while (cam - (double) (16 * (k - 1)) < 16.0 + MARGIN) { + k--; + } + while (!(cam - (double) (16 * k) < 16.0 + MARGIN)) { + k++; + } + return k; + } + + /** A main-view prepare served from SectionDrawCache: puts back the counters its cached build published. */ + static void restoreMainViewCounters(int skippedQuads, int drawnQuads, int addedDraws) { + quadsSkippedLastFrame = skippedQuads; + quadsDrawnLastFrame = drawnQuads; + drawsAddedLastFrame = addedDraws; + ungroupedSectionsLastFrame = 0; + } + + /** Share of the main view's opaque terrain quads left out last frame, in percent. */ + private static int skippedPercent() { + long skippedQuads = quadsSkippedLastFrame; + long all = skippedQuads + quadsDrawnLastFrame; + return all == 0 ? 0 : (int) (skippedQuads * 100 / all); + } + + /** + * For a later step of Etna's shadow pass: cull the faces turned away from a directional light in the + * next call of LevelRenderer.prepareChunkRenders / prepareChunkRendersIndirect that is not vanilla's main + * view (call it right before that prepare; it is used once, and forgotten at the end of the frame's + * main-view prepare if no prepare took it). {@code towardsLight} points from the terrain to the light + * (the sun or moon), in world axes; the view must be orthographic along it with back faces culled, so a + * face is drawn only when its normal has a positive part along it. Name only the layers whose pipeline + * culls back faces in that pass (Etna: SOLID with shadowCulling on; not CUTOUT, drawn double-sided). Does + * nothing while faceCulling (or the master switch) is off. Render thread only. + */ + public static void cullNextPrepareTowards(float towardsLightX, float towardsLightY, float towardsLightZ, + ChunkSectionLayer... layers) { + float length = (float) Math.sqrt(towardsLightX * towardsLightX + towardsLightY * towardsLightY + + towardsLightZ * towardsLightZ); + if (!(length > 0.0F)) { + armed = false; + return; + } + float x = towardsLightX / length; + float y = towardsLightY / length; + float z = towardsLightZ / length; + int mask = 1 << FaceSorter.UNASSIGNED; + mask |= x > -EDGE_ON ? 1 << FaceSorter.PX : 0; + mask |= x < EDGE_ON ? 1 << FaceSorter.NX : 0; + mask |= y > -EDGE_ON ? 1 << FaceSorter.PY : 0; + mask |= y < EDGE_ON ? 1 << FaceSorter.NY : 0; + mask |= z > -EDGE_ON ? 1 << FaceSorter.PZ : 0; + mask |= z < EDGE_ON ? 1 << FaceSorter.NZ : 0; + armedMask = mask; + java.util.Arrays.fill(armedLayers, false); + for (ChunkSectionLayer layer : layers) { + armedLayers[layer.ordinal()] = !layer.translucent(); + } + armed = true; + } + + /** Forgets a {@link #cullNextPrepareTowards} no prepare has taken yet. */ + public static void clearNextPrepare() { + armed = false; + } + + // Called by LevelRendererFacesMixin around vanilla's own prepare call in LevelRenderer.render. + + public static void beginMainView(Vec3 camera) { + mainViewCall = true; + camX = camera.x; + camY = camera.y; + camZ = camera.z; + } + + public static void endMainView() { + mainViewCall = false; + armed = false; + } + + /** Head of extractSectionDrawGroups: decides how this prepare cuts its draws. */ + public static void beginPrepare() { + skipped = 0; + drawn = 0; + added = 0; + ungrouped = 0; + regroupCount = 0; + minGap = effectiveMinGap(); + boolean on = FACE_CULLING.on(); + if (mainViewCall) { + mode = on ? MAIN : NONE; + lastMainViewKey = on ? (1L << 32) | minGap : 0L; + for (ChunkSectionLayer layer : LAYERS) { + layerOn[layer.ordinal()] = on && !layer.translucent() && culls(layer); + } + } else if (armed) { + armed = false; + mode = on ? DIRECTIONAL : NONE; + directionalMask = armedMask; + System.arraycopy(armedLayers, 0, layerOn, 0, layerOn.length); + } else { + mode = NONE; + } + } + + /** Return of extractSectionDrawGroups: publishes the counters, asks for rebuilds of ungrouped sections. */ + public static void endPrepare() { + if (mode == MAIN) { + quadsSkippedLastFrame = skipped; + quadsDrawnLastFrame = drawn; + drawsAddedLastFrame = added; + ungroupedSectionsLastFrame = ungrouped; + requestRegroups(); + } else if (mainViewCall) { + // Main view with face culling off. + quadsSkippedLastFrame = 0; + quadsDrawnLastFrame = 0; + drawsAddedLastFrame = 0; + ungroupedSectionsLastFrame = 0; + } else if (mode == DIRECTIONAL) { + directionalQuadsSkippedLastPass = skipped; + directionalDrawsAddedLastPass = added; + } + mode = NONE; + } + + /** + * Called right before {@code drawGroup.draws.add(new IndexedDraw(...))} of extractSectionDrawGroups, for one + * layer of one section (vanilla ignores that add's result). Returns true when vanilla's add should run as it + * is (the draw is kept whole); otherwise adds the one to three draws that cover the groups that can face the + * camera to {@code draws} itself and returns false. + */ + @SuppressWarnings("unchecked") + public static boolean keepWhole(List draws, Object element, Object sectionMesh, ChunkSectionLayer layer, + BlockPos origin) { + if (mode == NONE || !layerOn[layer.ordinal()] || !(element instanceof DynamicGpuData.IndexedDraw draw) + || !(sectionMesh instanceof FaceGroups mesh)) { + return true; + } + int[][] groups = mesh.vesuvio$faceGroups(); + if (groups == null) { + // Built while face culling was off: drawn whole; have it rebuilt (grouped) in the background. + ungrouped++; + if (mode == MAIN && !mesh.vesuvio$regroupAsked() && regroupCount < REGROUP_PER_FRAME) { + mesh.vesuvio$setRegroupAsked(); + int i = 3 * regroupCount++; + regroup[i] = origin.getX() >> 4; + regroup[i + 1] = origin.getY() >> 4; + regroup[i + 2] = origin.getZ() >> 4; + } + return true; + } + int[] starts = groups[layer.ordinal()]; + if (starts == null) { + return true; + } + int total = starts[FaceSorter.GROUPS]; + if (draw.firstIndex() != 0 || draw.indexCount() != total * 6) { + return true; + } + int visible = mode == MAIN ? mainMask(origin) : directionalMask; + if (visible == ALL) { + drawn += total; + return true; + } + + List out = (List) draws; + int runs = 0; + int quads = 0; + int runStart = -1; + int runEnd = 0; + for (int g = 0; g < FaceSorter.GROUPS; g++) { + int from = starts[g]; + int to = starts[g + 1]; + if (from == to || (visible & (1 << g)) == 0) { + continue; + } + if (runStart < 0) { + runStart = from; + } else if (from > runEnd && from - runEnd >= minGap) { + out.add(part(draw, runStart, runEnd)); + runs++; + quads += runEnd - runStart; + runStart = from; + } + runEnd = to; + } + if (runStart >= 0) { + out.add(part(draw, runStart, runEnd)); + runs++; + quads += runEnd - runStart; + } else { + // Every quad faces away. Keep one draw of the first quad (which the GPU culls, as it would have) + // rather than none: vanilla's draw lists must not end up empty. + out.add(part(draw, 0, 1)); + runs = 1; + quads = 1; + } + drawn += quads; + skipped += total - quads; + added += runs - 1; + return false; + } + + private static DynamicGpuData.IndexedDraw part(DynamicGpuData.IndexedDraw draw, int fromQuad, int toQuad) { + // The shared sequential quad index buffer: quad q is indices 6q .. 6q+5, naming vertices 4q .. 4q+3. + return new DynamicGpuData.IndexedDraw((toQuad - fromQuad) * 6, draw.instanceCount(), fromQuad * 6, + draw.baseVertex(), draw.baseInstance()); + } + + /** The groups that can face the camera, for a section whose corner is at {@code origin}. */ + private static int mainMask(BlockPos origin) { + double x = camX - origin.getX(); + double y = camY - origin.getY(); + double z = camZ - origin.getZ(); + int mask = 1 << FaceSorter.UNASSIGNED; + mask |= x > -MARGIN ? 1 << FaceSorter.PX : 0; + mask |= x < 16.0 + MARGIN ? 1 << FaceSorter.NX : 0; + mask |= y > -MARGIN ? 1 << FaceSorter.PY : 0; + mask |= y < 16.0 + MARGIN ? 1 << FaceSorter.NY : 0; + mask |= z > -MARGIN ? 1 << FaceSorter.PZ : 0; + mask |= z < 16.0 + MARGIN ? 1 << FaceSorter.NZ : 0; + return mask; + } + + /** + * Whether every pipeline vanilla can draw this layer with drops back faces (it does in vanilla; checked + * once, in case another mod changes that). Cutting a layer drawn double-sided would lose faces. + */ + private static boolean culls(ChunkSectionLayer layer) { + int i = layer.ordinal(); + if (layerCulls[i] == 0) { + boolean cull = layer.pipeline(false, false).isCull() && layer.pipeline(true, false).isCull() + && layer.pipeline(false, true).isCull() && layer.pipeline(true, true).isCull(); + layerCulls[i] = (byte) (cull ? 1 : 2); + if (!cull) { + Vesuvio.LOGGER.info("Vesuvio: {} terrain is drawn double-sided, face culling leaves it whole", layer.label()); + } + } + return layerCulls[i] == 1; + } + + /** + * Sections built while face culling was off are drawn whole. Once it is on, the visible ones are marked + * for a rebuild, a few each frame: the old mesh stays on screen until the new one is uploaded, and + * the fade-in does not run again, so nothing flickers. + */ + private static void requestRegroups() { + if (regroupCount == 0 || regroupFailed) { + return; + } + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.level == null) { + return; + } + try { + for (int i = 0; i < regroupCount; i++) { + minecraft.levelExtractor.setSectionDirty(regroup[3 * i], regroup[3 * i + 1], regroup[3 * i + 2]); + } + } catch (RuntimeException e) { + regroupFailed = true; + Vesuvio.LOGGER.warn("Vesuvio: could not ask for section rebuilds; sections built while face culling " + + "was off stay drawn whole until they are rebuilt", e); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/faces/FaceGroups.java b/src/main/java/it/cosimo/vesuvio/faces/FaceGroups.java new file mode 100644 index 0000000..8df2218 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/faces/FaceGroups.java @@ -0,0 +1,28 @@ +package it.cosimo.vesuvio.faces; + +import org.jspecify.annotations.Nullable; + +/** + * Added to CompiledSectionMesh (by CompiledSectionMeshMixin): where each facing group of quads starts in the + * mesh's opaque layers, as FaceSorter recorded it when the section was built. + */ +public interface FaceGroups { + /** + * Per layer (by ChunkSectionLayer ordinal): 8 quad offsets, group g being quads [s[g], s[g + 1]) in the + * order of {@link FaceSorter}. A null entry: that layer is not grouped (translucent, or a format the + * sorter does not know), drawn whole. Null as a whole: the section was built while face culling was off. + */ + int @Nullable [] @Nullable [] vesuvio$faceGroups(); + + /** True once a rebuild has been asked for this mesh to group it (it was built while face culling was off). */ + boolean vesuvio$regroupAsked(); + + void vesuvio$setRegroupAsked(); + + /** Added to SectionCompiler.Results: carries the groups from the compile to the mesh made of it. */ + interface Carrier { + int @Nullable [] @Nullable [] vesuvio$faceGroups(); + + void vesuvio$setFaceGroups(int @Nullable [] @Nullable [] groups); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/faces/FaceSorter.java b/src/main/java/it/cosimo/vesuvio/faces/FaceSorter.java new file mode 100644 index 0000000..04e2ea3 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/faces/FaceSorter.java @@ -0,0 +1,226 @@ +package it.cosimo.vesuvio.faces; + +import java.nio.ByteBuffer; +import java.util.Map; + +import org.jspecify.annotations.Nullable; +import org.lwjgl.system.MemoryUtil; + +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.VertexFormat; +import com.mojang.blaze3d.vertex.VertexFormatElement; +import com.mojang.renderpearl.api.GpuFormat; +import com.mojang.renderpearl.api.pipeline.PrimitiveTopology; + +import net.minecraft.client.renderer.chunk.ChunkSectionLayer; + +/** + * Build side of face culling, on the section compile threads: sorts the quads of each opaque layer of a + * freshly built section by the way they face, so that each facing is one contiguous run of the vertex + * buffer, and records where each run starts. + * + *

A quad goes in a direction group when its four corners lie exactly in one plane across an axis + * (x, y or z the same float in all four), that plane is on the block grid (a multiple of 1/64 block, + * within a block of the section) and both of its triangles (0 1 2 and 2 3 0, the shared quad index buffer) + * turn the same way. Which way they turn (the right-hand normal of the corners in buffer order, the side + * the GPU sees as the front) gives + or -. So the group is exactly the side from which the GPU's back-face + * culling would keep the quad: a +X quad at x = c can only be drawn by a camera with x greater than c. + * Everything else (crossed plants, rotated or slanted faces, fluid tops, faces just off the grid like + * Etna's waving leaves, which move in the vertex shader) goes in the "unassigned" group, always drawn. + * + *

The sort is stable, so quads keep their order inside a group: two quads in exactly the same place + * facing the same way (a grass block's side and its overlay) are in the same group and still draw in the + * same order. Quads in different groups can never both be front-facing in the same spot, so the depth + * test sees the same thing as before: the picture is unchanged. Only the order of whole quads changes; + * no vertex is altered, added or removed. + */ +public final class FaceSorter { + /** Group order in the vertex buffer. See FaceCulling for why this order. */ + public static final int PX = 0; + public static final int PZ = 1; + public static final int PY = 2; + public static final int UNASSIGNED = 3; + public static final int NZ = 4; + public static final int NX = 5; + public static final int NY = 6; + public static final int GROUPS = 7; + + /** Planes further than this outside the section (in blocks) are left unassigned: FaceCulling's bounds rest on it. */ + static final float PLANE_MARGIN = 1.0F; + private static final float GRID = 64.0F; + private static final float GRID_TOLERANCE = 0.01F; + + private static final ThreadLocal SCRATCH = ThreadLocal.withInitial(Scratch::new); + + private FaceSorter() { + } + + /** Per-thread working memory: one int per quad, and room to hold two quads while they are swapped. */ + private static final class Scratch { + int[] dest = new int[4096]; + ByteBuffer swap = ByteBuffer.allocateDirect(1024); + + int[] dest(int quads) { + if (dest.length < quads) { + dest = new int[Math.max(quads, dest.length * 2)]; + } + return dest; + } + + long swap(int quadBytes) { + if (swap.capacity() < 2 * quadBytes) { + swap = ByteBuffer.allocateDirect(2 * quadBytes); + } + return MemoryUtil.memAddress(swap); + } + } + + /** Groups every opaque layer of a compile's results. Never null: a non-null array marks a section built with face culling on. */ + public static int[][] groupAll(Map layers) { + int[][] groups = new int[ChunkSectionLayer.values().length][]; + for (Map.Entry entry : layers.entrySet()) { + ChunkSectionLayer layer = entry.getKey(); + if (!layer.translucent()) { + groups[layer.ordinal()] = sort(entry.getValue(), layer.vertexFormat()); + } + } + return groups; + } + + /** + * Sorts one layer's quads in place by facing group. Returns the 8 group start offsets (in quads; the + * last is the quad count), or null when the mesh is not something this understands (then nothing is + * touched and the layer is drawn whole). + */ + static int @Nullable [] sort(MeshData mesh, @Nullable VertexFormat format) { + MeshData.DrawState state = mesh.drawState(); + if (format == null || state.primitiveTopology() != PrimitiveTopology.QUADS || mesh.indexBuffer() != null) { + return null; + } + VertexFormatElement position = format.getElement("Position"); + // Floats (vanilla's BLOCK) or half floats (compactTerrainVertices): read as the GPU reads them. + boolean half = position != null && position.format() == GpuFormat.RGBA16_FLOAT; + if (position == null || position.format() != GpuFormat.RGB32_FLOAT && !half) { + return null; + } + int vertices = state.vertexCount(); + int stride = format.getVertexSize(); + ByteBuffer buffer = mesh.vertexBuffer(); + if (vertices <= 0 || vertices % 4 != 0 || !buffer.isDirect() || (long) vertices * stride > buffer.remaining()) { + return null; + } + long base = MemoryUtil.memAddress(buffer) + position.offset(); + int quads = vertices / 4; + int quadBytes = 4 * stride; + Scratch scratch = SCRATCH.get(); + int[] dest = scratch.dest(quads); + + // Pass 1: the group of each quad, and how many quads each group has. + int[] starts = new int[GROUPS + 1]; + boolean inOrder = true; + int previous = 0; + for (int q = 0; q < quads; q++) { + int group = classify(base + (long) q * quadBytes, stride, half); + dest[q] = group; + starts[group + 1]++; + inOrder &= group >= previous; + previous = group; + } + for (int g = 0; g < GROUPS; g++) { + starts[g + 1] += starts[g]; + } + if (inOrder) { + return starts; + } + + // Pass 2: where each quad goes (a stable counting sort). + int[] next = new int[GROUPS]; + System.arraycopy(starts, 0, next, 0, GROUPS); + for (int q = 0; q < quads; q++) { + dest[q] = next[dest[q]]++; + } + + // Pass 3: move the quads, following each cycle of the permutation, so only two quads of extra + // memory are needed. Each quad is copied out and in once. + long data = MemoryUtil.memAddress(buffer); + long carry = scratch.swap(quadBytes); + long spare = carry + quadBytes; + for (int start = 0; start < quads; start++) { + if (dest[start] == start) { + continue; + } + MemoryUtil.memCopy(data + (long) start * quadBytes, carry, quadBytes); + int at = start; + while (true) { + int to = dest[at]; + dest[at] = at; + if (to == start) { + MemoryUtil.memCopy(carry, data + (long) start * quadBytes, quadBytes); + break; + } + MemoryUtil.memCopy(data + (long) to * quadBytes, spare, quadBytes); + MemoryUtil.memCopy(carry, data + (long) to * quadBytes, quadBytes); + long t = carry; + carry = spare; + spare = t; + at = to; + } + } + return starts; + } + + /** The group of the quad whose first vertex position is at {@code v0} (three floats, or with {@code half} halves). */ + private static int classify(long v0, int stride, boolean half) { + long v1 = v0 + stride; + long v2 = v1 + stride; + long v3 = v2 + stride; + int c = half ? 2 : 4; + float x0 = get(v0, half), y0 = get(v0 + c, half), z0 = get(v0 + 2 * c, half); + float x1 = get(v1, half), y1 = get(v1 + c, half), z1 = get(v1 + 2 * c, half); + float x2 = get(v2, half), y2 = get(v2 + c, half), z2 = get(v2 + 2 * c, half); + float x3 = get(v3, half), y3 = get(v3 + c, half), z3 = get(v3 + 2 * c, half); + if (x0 == x1 && x0 == x2 && x0 == x3) { + // Normal's x of triangle 0 1 2 and of triangle 2 3 0. + float a = (y1 - y0) * (z2 - z0) - (z1 - z0) * (y2 - y0); + float b = (y3 - y2) * (z0 - z2) - (z3 - z2) * (y0 - y2); + return axis(x0, a, b, PX, NX); + } + if (y0 == y1 && y0 == y2 && y0 == y3) { + float a = (z1 - z0) * (x2 - x0) - (x1 - x0) * (z2 - z0); + float b = (z3 - z2) * (x0 - x2) - (x3 - x2) * (z0 - z2); + return axis(y0, a, b, PY, NY); + } + if (z0 == z1 && z0 == z2 && z0 == z3) { + float a = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0); + float b = (x3 - x2) * (y0 - y2) - (y3 - y2) * (x0 - x2); + return axis(z0, a, b, PZ, NZ); + } + return UNASSIGNED; + } + + private static float get(long pointer, boolean half) { + return half ? Float.float16ToFloat(MemoryUtil.memGetShort(pointer)) : MemoryUtil.memGetFloat(pointer); + } + + /** + * A quad in the plane at {@code plane} across an axis, its two triangles' normal components along that + * axis {@code a} and {@code b}. A triangle with no area draws nothing, so only the other one counts; + * triangles turned opposite ways (a folded quad) or a quad with no area at all stay unassigned. + */ + private static int axis(float plane, float a, float b, int positive, int negative) { + if (!(plane >= -PLANE_MARGIN && plane <= 16.0F + PLANE_MARGIN)) { + return UNASSIGNED; + } + float grid = plane * GRID; + if (Math.abs(grid - Math.round(grid)) > GRID_TOLERANCE) { + return UNASSIGNED; + } + if (a >= 0.0F && b >= 0.0F && (a > 0.0F || b > 0.0F)) { + return positive; + } + if (a <= 0.0F && b <= 0.0F && (a < 0.0F || b < 0.0F)) { + return negative; + } + return UNASSIGNED; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/faces/SectionDrawCache.java b/src/main/java/it/cosimo/vesuvio/faces/SectionDrawCache.java new file mode 100644 index 0000000..b4d2db2 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/faces/SectionDrawCache.java @@ -0,0 +1,280 @@ +package it.cosimo.vesuvio.faces; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntSupplier; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.frame.AnimatedTextures; +import it.cosimo.vesuvio.ui.VesuvioStats; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import net.minecraft.client.renderer.DynamicGpuData; +import net.minecraft.client.renderer.chunk.ChunkSectionLayer; +import net.minecraft.client.renderer.chunk.SectionMesh; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Util; +import org.jspecify.annotations.Nullable; + +/** + * Setting sectionDrawCache: the main view's terrain draw lists are reused while nothing they depend on changed. + * + *

Every frame, vanilla's LevelRenderer.extractSectionDrawGroups turns the visible sections into draw lists: for + * each section and layer it looks up the mesh's draw and its place in the chunk buffers (two hash lookups), groups + * the draws by buffer and makes a record for each (plus face culling's cut, FaceCulling). Its whole result is a + * function of: + *

    + *
  • the visible sections, in order, each with its mesh and origin (LevelRenderer.visibleSections);
  • + *
  • where each mesh sits in the chunk buffers (UberGpuBuffer allocations; counted by {@link #allocationsChanged}, + * called after every change to any of them: upload, free, close);
  • + *
  • each drawn section's fade-in (always 1 once the fade is over);
  • + *
  • respectTranslucentOrder, and for face culling its setting, split threshold and the camera's cell + * (FaceCulling.cameraCell: the camera may move as long as no section's facing groups change).
  • + *
+ * When all of these match the last build, the last lists are handed back as they were built; otherwise vanilla + * builds new ones and they are kept. The check runs under the chunk dispatcher's lock, like vanilla's own + * extraction, so no allocation can change during it. A build is not kept while sections fade in, or while face + * culling still has sections to regroup. The draw commands and section data are still written every frame by + * vanilla's code that follows (they go to per-frame GPU memory). + * + *

Only vanilla's own main-view call is served (the one FaceCulling marks); Etna's shadow pass and any other + * caller build their lists as usual. A hit still marks each visible mesh's animated textures as seen, as the + * extraction would have (AnimatedTextures). + */ +public final class SectionDrawCache { + public static final VesuvioConfig.Toggle CACHE = VesuvioConfig.toggle("rendering", "sectionDrawCache", true, false); + + private static final ChunkSectionLayer[] LAYERS = ChunkSectionLayer.values(); + private static final long WINDOW_NS = 1_000_000_000L; + + /** Changes to chunk buffer allocations so far (any thread, under the dispatcher's lock or on the render thread). */ + private static final AtomicInteger ALLOCATIONS = new AtomicInteger(); + + // Render thread only. The kept build: + private static boolean valid; + private static @Nullable SectionRenderDispatcher dispatcher; + private static boolean respectTranslucentOrder; + private static int allocations; + private static long faceKey; + private static final int[] cell = new int[6]; + private static final int[] cellNow = new int[6]; + private static int count; + private static Object[] sections = new Object[0]; + private static Object[] meshes = new Object[0]; + private static int[] origins = new int[0]; + /** Per visible section: it has a section info (something drawn), so its fade-in must stay over. */ + private static boolean[] drawn = new boolean[0]; + private static Object[] infos = new Object[0]; + @SuppressWarnings("unchecked") + private static final List[] groups = new List[LAYERS.length]; + private static int largestIndexCount; + private static int quadsSkipped; + private static int quadsDrawn; + private static int drawsAdded; + + private static long windowStart; + private static int windowCalls; + private static int windowHits; + /** Share of the main view's prepares in the last second served from the kept lists, in percent. */ + public static int reusedPercent; + + private SectionDrawCache() { + } + + /** Called from FaceCulling.init: makes the setting (on the Rendering page, after face culling's) and the counter. */ + static void init() { + VesuvioStats.register("draw lists reused %", () -> reusedPercent); + } + + /** + * The world is changing or being left (Vesuvio.levelChanged, render thread): forgets the kept build and lets go + * of the old world's sections, meshes, section infos and draw groups. The next main-view prepare builds as usual. + */ + public static void release() { + valid = false; + dispatcher = null; + Arrays.fill(sections, null); + Arrays.fill(meshes, null); + count = 0; + infos = new Object[0]; + Arrays.fill(groups, null); + } + + /** UberGpuBuffer (UberAllocationsMixin): an allocation was added, freed or dropped. */ + public static void allocationsChanged() { + ALLOCATIONS.incrementAndGet(); + } + + /** + * The wrapped extractSectionDrawGroups call of both prepare methods. Hands back the kept lists, or runs + * {@code build} (vanilla's extraction, filling {@code sectionInfos} and {@code drawGroups}) and keeps what it made. + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static int extract(@Nullable SectionRenderDispatcher sectionDispatcher, + ObjectArrayList visible, long fadeDuration, boolean respectOrder, + List sectionInfos, Map drawGroups, IntSupplier build) { + if (!FaceCulling.inMainView()) { + // Another caller (Etna's shadow pass): built as usual, and the kept main-view build stays. + return build.getAsInt(); + } + if (!CACHE.on() || sectionDispatcher == null || it.cosimo.vesuvio.culling.FogCulling.mainViewCutting()) { + valid = false; + return build.getAsInt(); + } + long now = System.nanoTime(); + if (now - windowStart >= WINDOW_NS) { + reusedPercent = windowCalls == 0 ? 0 : windowHits * 100 / windowCalls; + windowStart = now; + windowCalls = 0; + windowHits = 0; + } + windowCalls++; + sectionDispatcher.lock(); + try { + long key = FaceCulling.mainViewKey(); + boolean cellKnown = key == 0L || FaceCulling.cameraCell(cellNow); + if (key == 0L) { + Arrays.fill(cellNow, 0); + } + if (valid && cellKnown && sectionDispatcher == dispatcher && respectOrder == respectTranslucentOrder + && ALLOCATIONS.get() == allocations && key == faceKey && Arrays.equals(cellNow, cell) + && sameSections(visible, fadeDuration)) { + // Sized once (to the capacity addAll would give vanilla's fresh list), then filled: unlike + // addAll(Arrays.asList(infos)), this does not copy the whole kept array into a throwaway one first. + List infosOut = (List) sectionInfos; + if (infosOut instanceof ArrayList arrayList) { + arrayList.ensureCapacity(arrayList.size() + infos.length); + for (Object info : infos) { + arrayList.add(info); + } + } else { + infosOut.addAll(Arrays.asList(infos)); + } + Map> groupsOut = (Map) drawGroups; + for (ChunkSectionLayer layer : LAYERS) { + List kept = groups[layer.ordinal()]; + if (!kept.isEmpty()) { + groupsOut.get(layer).addAll(kept); + } + } + FaceCulling.restoreMainViewCounters(quadsSkipped, quadsDrawn, drawsAdded); + windowHits++; + return largestIndexCount; + } + int generation = ALLOCATIONS.get(); + int result = build.getAsInt(); + keep(sectionDispatcher, visible, fadeDuration, respectOrder, cellKnown, generation, + (List) sectionInfos, (Map) drawGroups, result); + return result; + } finally { + sectionDispatcher.unlock(); + } + } + + /** + * Whether the visible sections are the kept ones, in the same order, with the same meshes and origins and their + * fade-in over. Marks each mesh's animated textures as seen on the way (the extraction does that too). + */ + private static boolean sameSections(ObjectArrayList visible, long fadeDuration) { + int n = visible.size(); + if (n != count) { + return false; + } + Object[] elements = visible.elements(); + long millis = Util.getMillis(); + for (int i = 0; i < n; i++) { + SectionRenderDispatcher.RenderSection section = (SectionRenderDispatcher.RenderSection) elements[i]; + if (section != sections[i]) { + return false; + } + SectionMesh mesh = section.getSectionMesh(); + if (mesh != meshes[i]) { + return false; + } + BlockPos origin = section.getRenderOrigin(); + if (origin.getX() != origins[3 * i] || origin.getY() != origins[3 * i + 1] || origin.getZ() != origins[3 * i + 2]) { + return false; + } + if (drawn[i] && section.getVisibility(millis, fadeDuration) != 1.0F) { + return false; + } + AnimatedTextures.markMesh(mesh); + } + return true; + } + + /** After a build: keeps it when it can be reused exactly, else forgets the last one. */ + private static void keep(SectionRenderDispatcher sectionDispatcher, + ObjectArrayList visible, long fadeDuration, boolean respectOrder, + boolean cellKnown, int generation, List sectionInfos, Map> drawGroups, + int result) { + valid = false; + if (!cellKnown || FaceCulling.ungroupedSectionsLastFrame > 0) { + return; + } + for (Object info : sectionInfos) { + if (!(info instanceof DynamicGpuData.ChunkSectionInfo sectionInfo) || sectionInfo.visibility() != 1.0F) { + // Fading in: the section data changes every frame until it is over. + return; + } + } + int n = visible.size(); + if (sections.length < n) { + int size = Math.max(n, sections.length * 3 / 2 + 16); + sections = new Object[size]; + meshes = new Object[size]; + origins = new int[3 * size]; + drawn = new boolean[size]; + } + Object[] elements = visible.elements(); + int infoIndex = 0; + int drawnCount = 0; + for (int i = 0; i < n; i++) { + SectionRenderDispatcher.RenderSection section = (SectionRenderDispatcher.RenderSection) elements[i]; + sections[i] = section; + meshes[i] = section.getSectionMesh(); + BlockPos origin = section.getRenderOrigin(); + origins[3 * i] = origin.getX(); + origins[3 * i + 1] = origin.getY(); + origins[3 * i + 2] = origin.getZ(); + drawn[i] = false; + } + // Section infos are made in the order of the visible sections, one for each section with a draw, holding its + // origin: match them up to know which sections must stay faded in. + for (int i = 0; i < n && infoIndex < sectionInfos.size(); i++) { + DynamicGpuData.ChunkSectionInfo info = (DynamicGpuData.ChunkSectionInfo) sectionInfos.get(infoIndex); + if (info.x() == origins[3 * i] && info.y() == origins[3 * i + 1] && info.z() == origins[3 * i + 2]) { + drawn[i] = true; + infoIndex++; + drawnCount++; + } + } + if (drawnCount != sectionInfos.size()) { + // Not matched one to one (another mod changed the extraction?): don't reuse anything. + return; + } + for (int i = n; i < count; i++) { + sections[i] = null; + meshes[i] = null; + } + count = n; + infos = sectionInfos.toArray(); + for (ChunkSectionLayer layer : LAYERS) { + List built = drawGroups.get(layer); + groups[layer.ordinal()] = built == null ? List.of() : new ArrayList<>(built); + } + dispatcher = sectionDispatcher; + respectTranslucentOrder = respectOrder; + allocations = generation; + faceKey = FaceCulling.lastMainViewKey(); + System.arraycopy(cellNow, 0, cell, 0, 6); + largestIndexCount = result; + quadsSkipped = FaceCulling.quadsSkippedLastFrame; + quadsDrawn = FaceCulling.quadsDrawnLastFrame; + drawsAdded = FaceCulling.drawsAddedLastFrame; + valid = true; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/faces/mixin/CompiledSectionMeshMixin.java b/src/main/java/it/cosimo/vesuvio/faces/mixin/CompiledSectionMeshMixin.java new file mode 100644 index 0000000..7dd2901 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/faces/mixin/CompiledSectionMeshMixin.java @@ -0,0 +1,47 @@ +package it.cosimo.vesuvio.faces.mixin; + +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.faces.FaceGroups; +import net.minecraft.client.renderer.chunk.CompiledSectionMesh; +import net.minecraft.client.renderer.chunk.SectionCompiler; +import net.minecraft.client.renderer.chunk.TranslucencyPointOfView; + +/** + * Face culling: each section mesh keeps where its facing groups start (a few ints per opaque layer). Set in + * the constructor on the compile thread; the render thread reads it after the mesh is published through + * RenderSection.sectionMesh (an AtomicReference), which makes it visible there. + */ +@Mixin(CompiledSectionMesh.class) +abstract class CompiledSectionMeshMixin implements FaceGroups { + @Unique + private int @Nullable [] @Nullable [] vesuvio$groups; + @Unique + private boolean vesuvio$regroup; + + @Inject(method = "", at = @At("TAIL")) + private void vesuvio$keepFaceGroups(TranslucencyPointOfView translucencyPointOfView, SectionCompiler.Results results, + long compileTaskStartTimeNs, CallbackInfo ci) { + vesuvio$groups = ((FaceGroups.Carrier) (Object) results).vesuvio$faceGroups(); + } + + @Override + public int @Nullable [] @Nullable [] vesuvio$faceGroups() { + return vesuvio$groups; + } + + @Override + public boolean vesuvio$regroupAsked() { + return vesuvio$regroup; + } + + @Override + public void vesuvio$setRegroupAsked() { + vesuvio$regroup = true; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/faces/mixin/LevelRendererDrawCacheMixin.java b/src/main/java/it/cosimo/vesuvio/faces/mixin/LevelRendererDrawCacheMixin.java new file mode 100644 index 0000000..4351d51 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/faces/mixin/LevelRendererDrawCacheMixin.java @@ -0,0 +1,54 @@ +package it.cosimo.vesuvio.faces.mixin; + +import java.util.List; +import java.util.Map; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; + +import it.cosimo.vesuvio.faces.SectionDrawCache; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.client.renderer.state.OptionsRenderState; +import net.minecraft.util.Util; +import org.jspecify.annotations.Nullable; + +/** + * Setting sectionDrawCache ({@link SectionDrawCache}): the extractSectionDrawGroups call of both prepare methods + * goes through the cache, which serves vanilla's main-view call (marked by LevelRendererFacesMixin) from the last + * build when nothing it depends on changed, and runs the call as usual otherwise. + */ +@Mixin(LevelRenderer.class) +abstract class LevelRendererDrawCacheMixin { + @Shadow + @Final + private ObjectArrayList visibleSections; + + @Shadow + private @Nullable SectionRenderDispatcher sectionRenderDispatcher; + + @Shadow + @Final + private OptionsRenderState optionsRenderState; + + @WrapOperation( + method = {"prepareChunkRendersIndirect", "prepareChunkRenders"}, + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/renderer/LevelRenderer;extractSectionDrawGroups(ZLjava/util/List;Ljava/util/Map;)I" + ), + require = 2 + ) + private int vesuvio$cachedDrawGroups(LevelRenderer self, boolean respectTranslucentOrder, List sectionInfos, + Map drawGroups, Operation original) { + return SectionDrawCache.extract(this.sectionRenderDispatcher, this.visibleSections, + Util.toMillis(this.optionsRenderState.chunkSectionFadeInTime), respectTranslucentOrder, sectionInfos, + drawGroups, () -> original.call(self, respectTranslucentOrder, sectionInfos, drawGroups)); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/faces/mixin/LevelRendererFacesMixin.java b/src/main/java/it/cosimo/vesuvio/faces/mixin/LevelRendererFacesMixin.java new file mode 100644 index 0000000..342b86f --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/faces/mixin/LevelRendererFacesMixin.java @@ -0,0 +1,91 @@ +package it.cosimo.vesuvio.faces.mixin; + +import java.util.List; + +import org.joml.Matrix4fc; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.faces.FaceCulling; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.chunk.ChunkSectionLayer; +import net.minecraft.client.renderer.chunk.ChunkSectionsToRender; +import net.minecraft.client.renderer.chunk.SectionMesh; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.core.BlockPos; + +/** + * Face culling, draw side. Vanilla's own two prepare calls in render() are wrapped to mark them as the + * main view (Etna's shadow pass, injected just before those calls, runs outside the mark and keeps whole + * meshes). In extractSectionDrawGroups, the one place both prepare paths turn visible sections into + * IndexedDraws, each opaque draw is replaced by the draws of the facing groups that can be seen. + */ +@Mixin(LevelRenderer.class) +abstract class LevelRendererFacesMixin { + @WrapOperation( + method = "render", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/renderer/LevelRenderer;prepareChunkRendersIndirect(Lorg/joml/Matrix4fc;Z)Lnet/minecraft/client/renderer/chunk/ChunkSectionsToRender;" + ) + ) + private ChunkSectionsToRender vesuvio$mainViewIndirect(LevelRenderer self, Matrix4fc modelView, boolean respectTranslucentOrder, + Operation original, @Local(argsOnly = true) CameraRenderState camera) { + FaceCulling.beginMainView(camera.pos); + try { + return original.call(self, modelView, respectTranslucentOrder); + } finally { + FaceCulling.endMainView(); + } + } + + @WrapOperation( + method = "render", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/renderer/LevelRenderer;prepareChunkRenders(Lorg/joml/Matrix4fc;Z)Lnet/minecraft/client/renderer/chunk/ChunkSectionsToRender;" + ) + ) + private ChunkSectionsToRender vesuvio$mainView(LevelRenderer self, Matrix4fc modelView, boolean respectTranslucentOrder, + Operation original, @Local(argsOnly = true) CameraRenderState camera) { + FaceCulling.beginMainView(camera.pos); + try { + return original.call(self, modelView, respectTranslucentOrder); + } finally { + FaceCulling.endMainView(); + } + } + + @Inject(method = "extractSectionDrawGroups", at = @At("HEAD")) + private void vesuvio$beginPrepare(boolean respectTranslucentOrder, List sectionInfos, java.util.Map drawGroups, + CallbackInfoReturnable cir) { + FaceCulling.beginPrepare(); + } + + @Inject(method = "extractSectionDrawGroups", at = @At("RETURN")) + private void vesuvio$endPrepare(boolean respectTranslucentOrder, List sectionInfos, java.util.Map drawGroups, + CallbackInfoReturnable cir) { + FaceCulling.endPrepare(); + } + + /** + * {@code drawGroup.draws.add(new DynamicGpuData.IndexedDraw(...))}: the third List.add in the method, whose + * result vanilla drops. A WrapWithCondition: vanilla's add runs when the draw stays whole, else FaceCulling has + * added the cut draws itself. Per section and layer, so no Operation or local wrappers are allocated here. + */ + @WrapWithCondition( + method = "extractSectionDrawGroups", + at = @At(value = "INVOKE", target = "Ljava/util/List;add(Ljava/lang/Object;)Z", ordinal = 2) + ) + private boolean vesuvio$cutByFacing(List draws, Object draw, @Local SectionMesh sectionMesh, + @Local ChunkSectionLayer layer, @Local BlockPos renderOffset) { + return FaceCulling.keepWhole(draws, draw, sectionMesh, layer, renderOffset); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/faces/mixin/SectionCompilerMixin.java b/src/main/java/it/cosimo/vesuvio/faces/mixin/SectionCompilerMixin.java new file mode 100644 index 0000000..72b05c0 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/faces/mixin/SectionCompilerMixin.java @@ -0,0 +1,29 @@ +package it.cosimo.vesuvio.faces.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; + +import it.cosimo.vesuvio.faces.FaceCulling; +import it.cosimo.vesuvio.faces.FaceGroups; +import it.cosimo.vesuvio.faces.FaceSorter; +import net.minecraft.client.renderer.chunk.SectionCompiler; + +/** + * Face culling, build side: once a section's layers are built (and the translucent one sorted), sort the + * opaque layers' quads by facing, before anything is uploaded. Runs on the section compile threads (or the + * render thread for a section rebuilt at once); each compile has its own buffers. While face culling is + * off nothing is touched, as in vanilla. + */ +@Mixin(SectionCompiler.class) +abstract class SectionCompilerMixin { + /** A ModifyReturnValue handing back the same Results: no CallbackInfoReturnable per compile. */ + @ModifyReturnValue(method = "compile", at = @At("RETURN")) + private SectionCompiler.Results vesuvio$groupFaces(SectionCompiler.Results results) { + if (FaceCulling.FACE_CULLING.on()) { + ((FaceGroups.Carrier) (Object) results).vesuvio$setFaceGroups(FaceSorter.groupAll(results.renderedLayers)); + } + return results; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/faces/mixin/SectionCompilerResultsMixin.java b/src/main/java/it/cosimo/vesuvio/faces/mixin/SectionCompilerResultsMixin.java new file mode 100644 index 0000000..46a13e7 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/faces/mixin/SectionCompilerResultsMixin.java @@ -0,0 +1,25 @@ +package it.cosimo.vesuvio.faces.mixin; + +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import it.cosimo.vesuvio.faces.FaceGroups; +import net.minecraft.client.renderer.chunk.SectionCompiler; + +/** Face culling: the compile's results carry the facing groups to the mesh made of them. */ +@Mixin(SectionCompiler.Results.class) +abstract class SectionCompilerResultsMixin implements FaceGroups.Carrier { + @Unique + private int @Nullable [] @Nullable [] vesuvio$groups; + + @Override + public int @Nullable [] @Nullable [] vesuvio$faceGroups() { + return vesuvio$groups; + } + + @Override + public void vesuvio$setFaceGroups(int @Nullable [] @Nullable [] groups) { + vesuvio$groups = groups; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/faces/mixin/UberAllocationsMixin.java b/src/main/java/it/cosimo/vesuvio/faces/mixin/UberAllocationsMixin.java new file mode 100644 index 0000000..0b75001 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/faces/mixin/UberAllocationsMixin.java @@ -0,0 +1,42 @@ +package it.cosimo.vesuvio.faces.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.mojang.blaze3d.vertex.StagingBuffer; +import com.mojang.blaze3d.vertex.UberGpuBuffer; +import com.mojang.renderpearl.api.device.GpuDevice; + +import it.cosimo.vesuvio.faces.SectionDrawCache; + +/** + * Tells {@link SectionDrawCache} about every change to where meshes sit in the chunk buffers: an allocation made by + * an upload, one freed (re-upload, mesh released), and all of them dropped when the buffer closes. Each call comes + * after the change itself. Counting only; nothing else changes. + */ +@Mixin(UberGpuBuffer.class) +abstract class UberAllocationsMixin { + @Inject(method = "uploadStagedAllocations", at = @At(value = "INVOKE", + target = "Ljava/util/Map;put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", shift = At.Shift.AFTER)) + private void vesuvio$allocated(GpuDevice device, StagingBuffer.Uploader uploader, CallbackInfoReturnable cir) { + SectionDrawCache.allocationsChanged(); + } + + @ModifyExpressionValue(method = "freeAllocation", at = @At(value = "INVOKE", + target = "Ljava/util/Map;remove(Ljava/lang/Object;)Ljava/lang/Object;")) + private Object vesuvio$freed(Object removed) { + if (removed != null) { + SectionDrawCache.allocationsChanged(); + } + return removed; + } + + @Inject(method = "close", at = @At("RETURN")) + private void vesuvio$closed(CallbackInfo ci) { + SectionDrawCache.allocationsChanged(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/AnimatedTextures.java b/src/main/java/it/cosimo/vesuvio/frame/AnimatedTextures.java new file mode 100644 index 0000000..50828b3 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/AnimatedTextures.java @@ -0,0 +1,169 @@ +package it.cosimo.vesuvio.frame; + +import org.jspecify.annotations.Nullable; + +import it.cosimo.vesuvio.VesuvioConfig; +import net.minecraft.client.renderer.chunk.SectionMesh; +import net.minecraft.client.renderer.texture.SpriteContents; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; +import net.minecraft.client.resources.model.geometry.BakedQuad; + +/** + * Animated block textures (water, lava, fire, portal, sea lantern, prismarine, ...) are redrawn into the block + * atlas only while something on screen uses them. Vanilla redraws every animated sprite of every atlas on every + * texture tick (20 a second), one render pass per mip level, whether or not anything shows it. + * + *

How "on screen" is known, conservatively: + *

    + *
  • terrain: each compiled section mesh lists the animated sprites its block models and fluids use (collected on the + * compile worker), and the list is marked whenever vanilla (or Etna's shadow pass, which goes through the same + * code) prepares that section for drawing;
  • + *
  • baked quads drawn outside terrain (items anywhere, including the GUI; block models held or carried by entities; + * moving pistons and falling blocks) are marked as they are written;
  • + *
  • everything else that draws an atlas sprite (particles, entity fire, the fire/portal/in-wall overlays, block + * entity textures) reads its UVs through {@link TextureAtlasSprite}'s getters, and every getter call marks it;
  • + *
  • a mesh compiled while this was off, or one that still points at sprites from before a resource reload, counts + * as "unknown" and keeps every sprite animating.
  • + *
+ * A sprite not marked for {@link #GRACE_TICKS} texture ticks stops being redrawn (its animation clock keeps running, + * so it resumes in step with vanilla). When it is marked again it is redrawn on the next texture tick, at most one + * tick (50 ms) after it comes into view. Only the block atlas is affected; other atlases always animate. + */ +public final class AnimatedTextures { + public static final VesuvioConfig.Toggle SETTING = VesuvioConfig.toggle("frame", "animatedTextures", true, false); + + /** Texture ticks a sprite keeps animating after it was last seen. */ + static final int GRACE_TICKS = 8; + private static final int NEVER = -1_000_000; + + /** Animated block-atlas sprites not redrawn at the last texture tick (for the settings screen). */ + public static int pausedLastTick; + /** Animated block-atlas sprites in total at the last texture tick. */ + public static int animatedLastTick; + + /** Texture tick counter, render thread. */ + private static int now; + /** Mirror of SETTING.on(), refreshed every texture tick; plain field so the hot hooks read it for free. */ + public static boolean active; + /** Last texture tick something "unknown" was drawn: every sprite counts as seen then. */ + private static int unknownSeen = NEVER; + /** True only while the block atlas cycles its animations. */ + private static boolean pruning; + private static int pausedCounting; + private static int animatedCounting; + + private AnimatedTextures() { + } + + public static void init() { + // Touching the class creates the setting. F3, the settings screen's box, the stats log and the benchmark. + it.cosimo.vesuvio.ui.VesuvioStats.register("animated sprites paused", () -> pausedLastTick); + } + + /** AtlasManager.tick, before any atlas cycles. Render thread. */ + public static void beginTick() { + now++; + boolean on = SETTING.on(); + if (on && !active) { + // Nothing was marked while off: give everything one grace period. + unknownSeen = now; + } + active = on; + } + + /** TextureAtlas.cycleAnimationFrames, start. */ + public static void beginAtlas(boolean blockAtlas) { + pruning = active && blockAtlas; + if (pruning) { + pausedCounting = 0; + animatedCounting = 0; + } + } + + /** TextureAtlas.cycleAnimationFrames, end. */ + public static void endAtlas() { + if (pruning) { + pausedLastTick = pausedCounting; + animatedLastTick = animatedCounting; + } else if (!active) { + pausedLastTick = 0; + } + pruning = false; + } + + /** + * AnimationState.tick, end: true when a paused sprite must be redrawn now (it came back into view, or pruning + * stopped), so the caller marks it dirty. + */ + public static boolean afterStateTick(AnimationStateExt state) { + SpriteContents owner = state.vesuvio$owner(); + boolean visible = !pruning || owner == null || isSeen(owner); + if (pruning) { + animatedCounting++; + if (!visible) { + pausedCounting++; + } + } + if (state.vesuvio$stale() && visible) { + state.vesuvio$setStale(false); + return true; + } + return false; + } + + /** AnimationState.needsToDraw: vanilla's answer, turned to false for a sprite nobody sees. */ + public static boolean needsToDraw(AnimationStateExt state, boolean original) { + if (!original || !pruning) { + return original; + } + SpriteContents owner = state.vesuvio$owner(); + if (owner == null || isSeen(owner)) { + return true; + } + state.vesuvio$setStale(true); + return false; + } + + private static boolean isSeen(SpriteContents contents) { + int seen = Math.max(((SpriteContentsExt) contents).vesuvio$seen(), unknownSeen); + return now - seen <= GRACE_TICKS; + } + + public static void mark(SpriteContents contents) { + SpriteContentsExt ext = (SpriteContentsExt) contents; + // Write only on change: the same sprite is marked from several threads (fluids on compile workers). + if (ext.vesuvio$seen() != now) { + ext.vesuvio$setSeen(now); + } + } + + public static void markQuad(BakedQuad quad) { + if (active) { + mark(quad.materialInfo().sprite().contents()); + } + } + + public static void markUnknown() { + unknownSeen = now; + } + + /** A section about to be drawn (main view or Etna's shadow casters). Render thread. */ + public static void markMesh(@Nullable SectionMesh mesh) { + if (!active || !(mesh instanceof CompiledMeshExt ext)) { + return; + } + SpriteContents[] sprites = ext.vesuvio$animatedSprites(); + if (sprites == null) { + markUnknown(); + return; + } + for (SpriteContents sprite : sprites) { + if (!((SpriteContentsExt) sprite).vesuvio$live()) { + // The mesh predates a resource reload: its sprites are not the atlas's any more. + markUnknown(); + return; + } + mark(sprite); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/AnimationStateExt.java b/src/main/java/it/cosimo/vesuvio/frame/AnimationStateExt.java new file mode 100644 index 0000000..5b42246 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/AnimationStateExt.java @@ -0,0 +1,17 @@ +package it.cosimo.vesuvio.frame; + +import org.jspecify.annotations.Nullable; + +import net.minecraft.client.renderer.texture.SpriteContents; + +/** Added to SpriteContents.AnimationState: its sprite, and whether a redraw was skipped while hidden. */ +public interface AnimationStateExt { + @Nullable + SpriteContents vesuvio$owner(); + + void vesuvio$setOwner(SpriteContents owner); + + boolean vesuvio$stale(); + + void vesuvio$setStale(boolean stale); +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/CompiledMeshExt.java b/src/main/java/it/cosimo/vesuvio/frame/CompiledMeshExt.java new file mode 100644 index 0000000..9a0ea25 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/CompiledMeshExt.java @@ -0,0 +1,10 @@ +package it.cosimo.vesuvio.frame; + +import org.jspecify.annotations.Nullable; + +import net.minecraft.client.renderer.texture.SpriteContents; + +/** Added to CompiledSectionMesh: the animated sprites its geometry uses, or null when unknown. */ +public interface CompiledMeshExt { + SpriteContents @Nullable [] vesuvio$animatedSprites(); +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/FrameBoost.java b/src/main/java/it/cosimo/vesuvio/frame/FrameBoost.java new file mode 100644 index 0000000..3cb8ab1 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/FrameBoost.java @@ -0,0 +1,22 @@ +package it.cosimo.vesuvio.frame; + +/** + * The "frame" page: per-frame work vanilla does for nothing (animated textures nobody sees, full frame rate in an + * unfocused or minimized window), the MoltenVK backend settings and the GPU pass timers diagnostic. + */ +public final class FrameBoost { + private FrameBoost() { + } + + /** Creates this page's settings, in display order. */ + public static void init() { + AnimatedTextures.init(); + IdleFps.init(); + MoltenVkSettings.init(); + PipelineDiskCache.init(); + TransferBarriers.init(); + FramebufferCache.init(); + MappedVertexPools.init(); + GpuPassTimers.init(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/FramebufferCache.java b/src/main/java/it/cosimo/vesuvio/frame/FramebufferCache.java new file mode 100644 index 0000000..2aed0a7 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/FramebufferCache.java @@ -0,0 +1,227 @@ +package it.cosimo.vesuvio.frame; + +import java.nio.LongBuffer; + +import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkFramebufferCreateInfo; + +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.ui.VesuvioStats; + +/** + * Setting framebufferCache (research-vulkan-engine.md, "Smaller items"). Vanilla's VulkanCommandEncoder.createRenderPass + * makes a new VkFramebuffer for every render pass it begins and queues it for destruction straight away, so a frame + * creates and destroys one per pass (20 or so in vanilla, many more with a shader pack). A framebuffer is only a + * list of image views tied to a render pass layout and a size; the same pass over the same targets asks for an + * identical one every frame. + * + *

Here the framebuffer is kept, keyed by everything vkCreateFramebuffer is given: the VkRenderPass, the attachment + * views in order (VK_NULL_HANDLE slots included), width, height and layers (flags and pNext must be 0, which is + * what vanilla passes; anything else is created as usual and not kept). The next pass with the same key begins on the + * kept framebuffer. Vulkan only reads a framebuffer's views and size when a pass begins, so an identical one gives the + * same rendering. + * + *

When it is destroyed: + *

    + *
  • an attachment view is closed (VulkanGpuTextureView.close): every kept framebuffer that uses it is queued on the + * encoder's destroy queue before the view itself is, so it goes when the GPU is done with the frames that used it, + * exactly like vanilla's per-pass framebuffer, and never outlives its view. A view handle cannot be reused for a new + * view while a framebuffer naming it is still kept, so a stale key can never match;
  • + *
  • more than {@link #MAX} are kept: the least recently used one is queued the same way;
  • + *
  • the setting (or the master switch) is turned off: all of them are queued at the next pass, which then runs as + * vanilla;
  • + *
  • the device closes (VulkanCommandEncoder.destroy): all of them are queued before the encoder drains its queue + * after waiting for the GPU.
  • + *
+ * + *

Clear fusion (clears/) may end a pass and begin it again on the framebuffer it began with: that happens inside + * one pass, while the framebuffer is in use and so cannot have been queued (a view cannot be closed in the middle of + * a frame's use of it and destroyed before the frame ends: destruction is deferred like vanilla's). It may also begin + * the pass again with a load-only VkRenderPass from the RenderPassCache: that pass is compatible with the one the + * framebuffer was made for (same formats, only load ops differ), as vanilla's per-pass framebuffer also relied on. + * + *

Render thread, like the encoder; the table is locked anyway because a view may in principle be closed elsewhere. + */ +public final class FramebufferCache { + /** Keep framebuffers across passes and frames. Live. */ + public static final VesuvioConfig.Toggle SETTING = VesuvioConfig.toggle("frame", "framebufferCache", true, false); + + /** Most framebuffers kept at once. Vanilla uses about 20 distinct ones a frame; shader packs a few dozen. */ + static final int MAX = 128; + + private static volatile int reusedLastFrame; + private static volatile int keptCount; + + private FramebufferCache() { + } + + public static void init() { + VesuvioStats.register("framebuffers reused", () -> reusedLastFrame); + VesuvioStats.register("framebuffers kept", () -> keptCount); + } + + /** The kept framebuffers of one encoder (one device). */ + public static final class Table { + private final long[] renderPasses = new long[MAX]; + private final long[][] views = new long[MAX][]; + private final int[] widths = new int[MAX]; + private final int[] heights = new int[MAX]; + private final int[] layers = new int[MAX]; + private final int[] hashes = new int[MAX]; + private final long[] framebuffers = new long[MAX]; + private final long[] lastUse = new long[MAX]; + private int count; + private long useClock; + private int reused; + private final VkDevice device; + + public Table(VkDevice device) { + this.device = device; + } + + /** Whether a framebuffer with this create info may be kept: vanilla's plain form only. */ + public static boolean cacheable(VkFramebufferCreateInfo info) { + return info.flags() == 0 && info.pNext() == 0L && info.pAttachments() != null; + } + + /** The kept framebuffer for this create info, or 0 (VK_NULL_HANDLE) when there is none. */ + public synchronized long find(VkFramebufferCreateInfo info) { + LongBuffer attachments = info.pAttachments(); + long renderPass = info.renderPass(); + int width = info.width(); + int height = info.height(); + int layerCount = info.layers(); + int hash = hash(renderPass, attachments, width, height, layerCount); + for (int i = 0; i < count; i++) { + if (hashes[i] == hash && renderPasses[i] == renderPass && widths[i] == width && heights[i] == height + && layers[i] == layerCount && sameViews(views[i], attachments)) { + lastUse[i] = ++useClock; + reused++; + return framebuffers[i]; + } + } + return VK10.VK_NULL_HANDLE; + } + + /** Keeps a framebuffer just created from this info; when full, the least recently used one is queued. */ + public synchronized void put(VkFramebufferCreateInfo info, long framebuffer, VulkanCommandEncoder encoder) { + LongBuffer attachments = info.pAttachments(); + int slot; + if (count < MAX) { + slot = count++; + } else { + slot = 0; + for (int i = 1; i < MAX; i++) { + if (lastUse[i] < lastUse[slot]) { + slot = i; + } + } + queueDestroy(encoder, framebuffers[slot]); + } + long[] copy = new long[attachments.remaining()]; + for (int j = 0; j < copy.length; j++) { + copy[j] = attachments.get(attachments.position() + j); + } + renderPasses[slot] = info.renderPass(); + views[slot] = copy; + widths[slot] = info.width(); + heights[slot] = info.height(); + layers[slot] = info.layers(); + hashes[slot] = hash(renderPasses[slot], attachments, widths[slot], heights[slot], layers[slot]); + framebuffers[slot] = framebuffer; + lastUse[slot] = ++useClock; + keptCount = count; + } + + /** An attachment view is being closed: every kept framebuffer naming it is queued for destruction. */ + public synchronized void viewClosed(long view, VulkanCommandEncoder encoder) { + for (int i = count - 1; i >= 0; i--) { + if (contains(views[i], view)) { + queueDestroy(encoder, framebuffers[i]); + removeAt(i); + } + } + keptCount = count; + } + + /** Queues every kept framebuffer for destruction (setting off, device closing). */ + public synchronized void clear(VulkanCommandEncoder encoder) { + for (int i = 0; i < count; i++) { + queueDestroy(encoder, framebuffers[i]); + views[i] = null; + } + count = 0; + keptCount = 0; + } + + public synchronized boolean isEmpty() { + return count == 0; + } + + /** The frame was submitted: publish the counter. */ + public synchronized void endFrame() { + reusedLastFrame = reused; + reused = 0; + } + + private void removeAt(int i) { + int last = count - 1; + if (i != last) { + renderPasses[i] = renderPasses[last]; + views[i] = views[last]; + widths[i] = widths[last]; + heights[i] = heights[last]; + layers[i] = layers[last]; + hashes[i] = hashes[last]; + framebuffers[i] = framebuffers[last]; + lastUse[i] = lastUse[last]; + } + views[last] = null; + count = last; + } + + private void queueDestroy(VulkanCommandEncoder encoder, long framebuffer) { + // The same deferred destruction vanilla queues for its per-pass framebuffer. + VkDevice vkDevice = this.device; + encoder.queueForDestroy(() -> VK10.vkDestroyFramebuffer(vkDevice, framebuffer, null)); + } + + private static boolean sameViews(long[] kept, LongBuffer attachments) { + int n = attachments.remaining(); + if (kept.length != n) { + return false; + } + int base = attachments.position(); + for (int j = 0; j < n; j++) { + if (kept[j] != attachments.get(base + j)) { + return false; + } + } + return true; + } + + private static boolean contains(long[] kept, long view) { + for (long v : kept) { + if (v == view) { + return true; + } + } + return false; + } + + private static int hash(long renderPass, LongBuffer attachments, int width, int height, int layerCount) { + long h = renderPass * 31L + width; + h = h * 31L + height; + h = h * 31L + layerCount; + int base = attachments.position(); + int n = attachments.remaining(); + for (int j = 0; j < n; j++) { + h = h * 31L + attachments.get(base + j); + } + return (int) (h ^ (h >>> 32)); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/FramebufferCacheHolder.java b/src/main/java/it/cosimo/vesuvio/frame/FramebufferCacheHolder.java new file mode 100644 index 0000000..537df3b --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/FramebufferCacheHolder.java @@ -0,0 +1,6 @@ +package it.cosimo.vesuvio.frame; + +/** Added to VulkanCommandEncoder by FrameFramebufferCacheMixin: its kept framebuffers (null until the first one). */ +public interface FramebufferCacheHolder { + FramebufferCache.Table vesuvio$framebuffers(); +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/GpuPassTimers.java b/src/main/java/it/cosimo/vesuvio/frame/GpuPassTimers.java new file mode 100644 index 0000000..2d57729 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/GpuPassTimers.java @@ -0,0 +1,610 @@ +package it.cosimo.vesuvio.frame; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.IntBuffer; +import java.nio.LongBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.vulkan.KHRSynchronization2; +import org.lwjgl.vulkan.VK12; +import org.lwjgl.vulkan.VkCommandBuffer; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkPhysicalDevice; +import org.lwjgl.vulkan.VkPhysicalDeviceProperties; +import org.lwjgl.vulkan.VkQueryPoolCreateInfo; +import org.lwjgl.vulkan.VkQueueFamilyProperties; + +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; +import com.mojang.renderpearl.backend.vulkan.VulkanDevice; + +import it.cosimo.vesuvio.Vesuvio; +import it.cosimo.vesuvio.VesuvioConfig; +import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; + +/** + * Setting gpuPassTimers: where the graphics card's time goes, per render pass. A diagnostic, off by default; the + * benchmark switches it on for one step of its own and logs a "GPU passes" table. + * + *

How it measures (FrameGpuTimersEncoderMixin, FrameGpuTimersStagingMixin): each frame in flight (the backend + * keeps two) has its own Vulkan timestamp query pool. Timestamps are written with vkCmdWriteTimestamp2 (the backend + * already requires synchronization2), stage ALL_COMMANDS, like vanilla's own writeTimestamp: + *

    + *
  • right before vkCmdBeginRenderPass2 and right after vkCmdEndRenderPass2 of every render pass, so always + * outside the render pass instance: on Apple GPUs MoltenVK can only sample time between Metal encoders, and a + * sample inside a pass could split it;
  • + *
  • at the start and the end of each command buffer of the frame: the main ones, the object-init one (new + * textures and buffers) and the transient staging one (both counted as "uploads").
  • + *
+ * An ALL_COMMANDS timestamp is taken once everything recorded before it has finished, so the time of a pass is + * from the end of what came before it to its own end. Vanilla already puts a full barrier after every pass, so the + * passes run one after the other anyway and these times add up. The frame is the sum of its command buffers' spans + * (any wait for a semaphore between them is left out); "between passes" is what the frame spent outside passes and + * uploads: copies, clears, barriers. The copy to the window (the swapchain blit, its own command buffer recorded by + * the surface) is not measured. + * + *

A pool is reset from the CPU (vkResetQueryPool; the backend requires hostQueryReset) at the start of its frame, + * which is right after the backend waited for the frame that used it before, and read the same moment, before the + * reset, without waiting (vkGetQueryPoolResults with no WAIT flag; a frame whose results are not all there is + * skipped). Pools are made on first use (512 queries, doubled up to 8192 when a frame needs more; a frame that did not + * fit is skipped), and queued on the backend's own deferred destroy queue when the setting goes off and when the + * device closes. With the setting off nothing is written, and no pool exists. + * + *

Passes are grouped by their label (the name each pass is created with, vanilla's and Etna's alike); the first + * 64 different labels are kept, later ones go to "other". Times are averaged over a second for F3 and over the + * benchmark step for its table. + */ +public final class GpuPassTimers { + /** Measure the GPU time of every render pass. Live, off by default. */ + public static final VesuvioConfig.Toggle SETTING = VesuvioConfig.toggle("frame", "gpuPassTimers", false, false); + + static final int MAX_LABELS = 64; + /** The bucket of labels past the first {@link #MAX_LABELS}. */ + static final int OTHER = MAX_LABELS; + private static final int BUCKETS = MAX_LABELS + 1; + /** Kinds of measured spans that are not passes: a main command buffer, and an upload command buffer. */ + private static final int KIND_FRAME = -1; + private static final int KIND_UPLOADS = -2; + + private static final int FIRST_POOL_SIZE = 512; + private static final int MAX_POOL_SIZE = 8192; + private static final long STAGE_ALL_COMMANDS = 0x10000L; + private static final int QUERY_TYPE_TIMESTAMP = 2; + private static final int QUERY_RESULT_64_BIT = 1; + private static final long WINDOW_NS = 1_000_000_000L; + private static final int DEBUG_TOP = 5; + private static final int TABLE_TOP = 15; + + private static final int UNKNOWN = 0; + private static final int YES = 1; + private static final int NO = -1; + /** Whether the graphics queue can write timestamps (checked once, from the first frame on). */ + private static volatile int support = UNKNOWN; + /** Nanoseconds per timestamp tick, re-read every second while measuring (MoltenVK refines it as it runs). */ + private static double period = 1.0; + /** Mask of the timestamp bits that count (timestampValidBits). */ + private static long validMask = -1L; + + // Render thread only. + private static final Object2IntOpenHashMap LABEL_IDS = new Object2IntOpenHashMap<>(); + private static final String[] LABEL_NAMES = new String[BUCKETS]; + private static int labelCount; + + // Totals since start-up, for the benchmark (differences between two reads). + private static long framesTotal; + private static long skippedTotal; + private static long frameNsTotal; + private static long uploadsNsTotal; + private static final long[] PASS_NS_TOTAL = new long[BUCKETS]; + + // The F3 window. + private static long windowStart; + private static int windowFrames; + private static long windowFrameNs; + private static long windowUploadsNs; + private static final long[] WINDOW_PASS_NS = new long[BUCKETS]; + private static volatile List debugLines = List.of(); + + static { + LABEL_IDS.defaultReturnValue(-1); + LABEL_NAMES[OTHER] = "other"; + } + + private GpuPassTimers() { + } + + /** Creates the setting (FrameBoost.init, last on the Frame page). */ + public static void init() { + } + + /** Added to VulkanCommandEncoder by FrameGpuTimersEncoderMixin: its recorder, null while not measuring. */ + public interface Host { + @Nullable + Recorder vesuvio$gpuTimers(); + } + + /** True once the graphics queue was found unable to write timestamps: the setting does nothing. */ + public static boolean unsupported() { + return support == NO; + } + + /** + * Start of a frame's recording (VulkanCommandEncoder.submit, after it waited for the frame that used this slot + * and before the next staging command buffer begins). Returns the recorder to use for the frame, or null. + */ + public static @Nullable Recorder frameStart(@Nullable Recorder recorder, VulkanDevice device, + VulkanCommandEncoder encoder, int slot) { + boolean supported = detect(device); + if (!supported || !SETTING.on()) { + if (recorder != null) { + recorder.release(encoder); + resetWindow(); + } + return null; + } + Recorder r = recorder != null ? recorder : new Recorder(device.vkDevice()); + r.frameStart(slot); + return r; + } + + /** Reads the queue family's timestampValidBits and the device's timestampPeriod, once. */ + private static boolean detect(VulkanDevice device) { + if (support != UNKNOWN) { + return support == YES; + } + try (MemoryStack stack = MemoryStack.stackPush()) { + VkPhysicalDevice physical = device.vkDevice().getPhysicalDevice(); + IntBuffer count = stack.mallocInt(1); + VK12.vkGetPhysicalDeviceQueueFamilyProperties(physical, count, null); + VkQueueFamilyProperties.Buffer families = VkQueueFamilyProperties.calloc(count.get(0), stack); + VK12.vkGetPhysicalDeviceQueueFamilyProperties(physical, count, families); + int family = device.graphicsQueue().queueFamilyIndex(); + int bits = family >= 0 && family < families.capacity() ? families.get(family).timestampValidBits() : 0; + float tick = readPeriod(physical); + if (bits > 0 && tick > 0.0F) { + validMask = bits >= 64 ? -1L : (1L << bits) - 1L; + period = tick; + support = YES; + } else { + support = NO; + Vesuvio.LOGGER.info("Vesuvio gpuPassTimers: this graphics queue cannot write timestamps" + + " (timestampValidBits {}, timestampPeriod {}); the setting does nothing", bits, tick); + } + } catch (RuntimeException e) { + support = NO; + Vesuvio.LOGGER.warn("Vesuvio gpuPassTimers: could not check timestamp support; the setting does nothing", e); + } + return support == YES; + } + + private static float readPeriod(VkPhysicalDevice physical) { + try (MemoryStack stack = MemoryStack.stackPush()) { + VkPhysicalDeviceProperties properties = VkPhysicalDeviceProperties.calloc(stack); + VK12.vkGetPhysicalDeviceProperties(physical, properties); + return properties.limits().timestampPeriod(); + } + } + + /** The bucket of a pass label: one of the first {@link #MAX_LABELS} names, or {@link #OTHER}. */ + private static int labelId(@Nullable Supplier label) { + String name; + try { + name = label == null ? null : label.get(); + } catch (RuntimeException e) { + name = null; + } + if (name == null || name.isEmpty()) { + name = "unnamed"; + } + int id = LABEL_IDS.getInt(name); + if (id >= 0) { + return id; + } + if (labelCount >= MAX_LABELS) { + return OTHER; + } + id = labelCount++; + LABEL_IDS.put(name, id); + LABEL_NAMES[id] = name; + return id; + } + + // ---- results ---- + + /** One frame's times, in nanoseconds (passNs per bucket). */ + private static void measured(long frameNs, long uploadsNs, long[] passNs) { + framesTotal++; + frameNsTotal += frameNs; + uploadsNsTotal += uploadsNs; + windowFrames++; + windowFrameNs += frameNs; + windowUploadsNs += uploadsNs; + for (int b = 0; b < BUCKETS; b++) { + PASS_NS_TOTAL[b] += passNs[b]; + WINDOW_PASS_NS[b] += passNs[b]; + } + } + + private static void skipped() { + skippedTotal++; + } + + /** Closes the F3 window when a second is over (render thread, once a frame while measuring). */ + private static void tick(long now) { + if (windowStart == 0L) { + windowStart = now; + return; + } + if (now - windowStart < WINDOW_NS) { + return; + } + List lines = new ArrayList<>(DEBUG_TOP + 1); + int n = windowFrames; + if (n == 0) { + lines.add("gpu pass timers: no frame measured in the last second"); + } else { + long passes = 0L; + for (long ns : WINDOW_PASS_NS) { + passes += ns; + } + double frame = windowFrameNs / (double) n / 1.0e6; + double uploads = windowUploadsNs / (double) n / 1.0e6; + double between = Math.max(0L, windowFrameNs - windowUploadsNs - passes) / (double) n / 1.0e6; + lines.add(String.format(Locale.ROOT, "gpu frame %.2f ms (passes %.2f, uploads %.2f, between %.2f)", frame, + passes / (double) n / 1.0e6, uploads, between)); + for (int b : top(WINDOW_PASS_NS, DEBUG_TOP)) { + double ms = WINDOW_PASS_NS[b] / (double) n / 1.0e6; + lines.add(String.format(Locale.ROOT, "gpu: %s %.2f ms %d%%", clip(LABEL_NAMES[b], 32), ms, + Math.round(frame > 0.0 ? ms / frame * 100.0 : 0.0))); + } + } + debugLines = List.copyOf(lines); + windowStart = now; + windowFrames = 0; + windowFrameNs = 0L; + windowUploadsNs = 0L; + Arrays.fill(WINDOW_PASS_NS, 0L); + } + + private static void resetWindow() { + windowStart = 0L; + windowFrames = 0; + windowFrameNs = 0L; + windowUploadsNs = 0L; + Arrays.fill(WINDOW_PASS_NS, 0L); + debugLines = List.of(); + } + + /** The buckets with time in them, largest first, at most {@code max}. */ + private static int[] top(long[] ns, int max) { + Integer[] order = new Integer[BUCKETS]; + int n = 0; + for (int b = 0; b < BUCKETS; b++) { + if (ns[b] > 0L) { + order[n++] = b; + } + } + Arrays.sort(order, 0, n, (a, b) -> Long.compare(ns[b], ns[a])); + int[] out = new int[Math.min(n, max)]; + for (int i = 0; i < out.length; i++) { + out[i] = order[i]; + } + return out; + } + + /** F3 lines (VesuvioDebugEntry): empty while the setting is off. */ + public static List debugLines() { + if (!SETTING.on()) { + return List.of(); + } + if (support == NO) { + return List.of("gpu pass timers: not supported by this graphics driver"); + } + List lines = debugLines; + return lines.isEmpty() ? List.of("gpu pass timers: measuring...") : lines; + } + + // ---- benchmark ---- + + /** Totals since start-up; two of them make a measured period ({@link #table}). */ + public record Totals(long frames, long skipped, long frameNs, long uploadsNs, long[] passNs) { + } + + public static Totals totals() { + return new Totals(framesTotal, skippedTotal, frameNsTotal, uploadsNsTotal, PASS_NS_TOTAL.clone()); + } + + /** The benchmark's "GPU passes" table for the frames measured between two totals (latest.log). */ + public static String table(String stepName, Totals from, Totals to) { + String nl = System.lineSeparator(); + long frames = to.frames - from.frames; + long skippedFrames = to.skipped - from.skipped; + if (support == NO) { + return "GPU passes: not measured, this graphics driver cannot write timestamps." + nl; + } + if (frames <= 0) { + return "GPU passes: no frame measured" + (skippedFrames > 0 ? " (" + skippedFrames + " skipped)" : "") + "." + + nl; + } + long[] pass = new long[BUCKETS]; + long passes = 0L; + for (int b = 0; b < BUCKETS; b++) { + pass[b] = to.passNs[b] - from.passNs[b]; + passes += pass[b]; + } + long frameNs = to.frameNs - from.frameNs; + long uploadsNs = to.uploadsNs - from.uploadsNs; + double frame = frameNs / (double) frames / 1.0e6; + StringBuilder t = new StringBuilder(); + t.append(String.format(Locale.ROOT, + "GPU passes (step \"%s\", %d frames%s): graphics card time per frame, from Vulkan timestamps around each" + + " render pass%n", + stepName, frames, skippedFrames > 0 ? ", " + skippedFrames + " skipped" : "")); + t.append(String.format(Locale.ROOT, " %-44s %8s %7s%n", "pass", "ms", "share")); + row(t, "whole frame", frame, frame); + int[] named = top(pass, TABLE_TOP); + long shown = 0L; + for (int b : named) { + if (b == OTHER) { + continue; + } + shown += pass[b]; + row(t, LABEL_NAMES[b], pass[b] / (double) frames / 1.0e6, frame); + } + long rest = passes - shown; + if (rest > 0L) { + row(t, "other passes", rest / (double) frames / 1.0e6, frame); + } + row(t, "uploads (staging, new textures and buffers)", uploadsNs / (double) frames / 1.0e6, frame); + row(t, "between passes (copies, clears, barriers)", + Math.max(0L, frameNs - uploadsNs - passes) / (double) frames / 1.0e6, frame); + return t.toString(); + } + + private static void row(StringBuilder t, String name, double ms, double frame) { + t.append(String.format(Locale.ROOT, " %-44s %8.3f %6.1f%%%n", clip(name, 44), ms, + frame > 0.0 ? ms / frame * 100.0 : 0.0)); + } + + private static String clip(String s, int n) { + return s.length() <= n ? s : s.substring(0, n - 1) + "~"; + } + + // ---- recording ---- + + /** One frame in flight's query pool and what its queries hold. */ + private static final class Slot { + long pool; + int size; + /** Spans recorded; span i owns queries 2i (start) and 2i + 1 (end). */ + int spans; + /** Each span's pass bucket, or KIND_FRAME / KIND_UPLOADS. */ + int[] kinds = new int[0]; + /** A span did not fit: the frame is skipped and the pool grows. */ + boolean overflow; + /** The pool was reset and handed to a frame, whose results are read when the slot comes round again. */ + boolean recorded; + } + + /** + * The timestamps of one command encoder (render thread only, like the encoder). Every method is cheap and does + * nothing while no frame is being measured. + */ + public static final class Recorder { + private final VkDevice device; + private final Slot[] slots = {new Slot(), new Slot()}; + private @Nullable Slot current; + /** The span of the open render pass, or -1. */ + private int passSpan = -1; + /** Open command buffers (address) and their spans: at most the staging, init and main ones at a time. */ + private final long[] openBuffers = new long[4]; + private final int[] openSpans = new int[4]; + private int openCount; + private final long[] passNs = new long[BUCKETS]; + private LongBuffer results = directLongs(FIRST_POOL_SIZE); + private long lastPeriodRead; + private boolean createFailed; + + Recorder(VkDevice device) { + this.device = device; + } + + /** The slot's earlier frame has finished on the GPU: read it, then reset the pool for the new frame. */ + void frameStart(int slotIndex) { + Slot s = this.slots[slotIndex & 1]; + long now = System.nanoTime(); + if (s.recorded) { + collect(s); + tick(now); + } + s.recorded = false; + this.current = null; + this.passSpan = -1; + this.openCount = 0; + if (now - this.lastPeriodRead > WINDOW_NS) { + this.lastPeriodRead = now; + float tick = readPeriod(this.device.getPhysicalDevice()); + if (tick > 0.0F) { + period = tick; + } + } + if (s.pool == 0L || s.overflow && s.size < MAX_POOL_SIZE) { + int size = s.pool == 0L ? FIRST_POOL_SIZE : Math.min(s.size * 2, MAX_POOL_SIZE); + if (s.pool != 0L) { + // Its frame has finished (the backend just waited for it), and nothing else uses this pool. + VK12.vkDestroyQueryPool(this.device, s.pool, null); + s.pool = 0L; + } + long pool = createPool(size); + if (pool == 0L) { + return; + } + s.pool = pool; + s.size = size; + s.kinds = new int[size / 2]; + if (this.results.capacity() < size) { + this.results = directLongs(size); + } + } + VK12.vkResetQueryPool(this.device, s.pool, 0, s.size); + s.spans = 0; + s.overflow = false; + s.recorded = true; + this.current = s; + } + + private long createPool(int size) { + try (MemoryStack stack = MemoryStack.stackPush()) { + VkQueryPoolCreateInfo info = VkQueryPoolCreateInfo.calloc(stack).sType$Default(); + info.queryType(QUERY_TYPE_TIMESTAMP); + info.queryCount(size); + LongBuffer pool = stack.callocLong(1); + int result = VK12.vkCreateQueryPool(this.device, info, null, pool); + if (result == VK12.VK_SUCCESS) { + return pool.get(0); + } + if (!this.createFailed) { + this.createFailed = true; + Vesuvio.LOGGER.warn("Vesuvio gpuPassTimers: could not create a timestamp query pool (VkResult {})", + result); + } + return 0L; + } + } + + private void collect(Slot s) { + if (s.overflow || s.spans == 0) { + skipped(); + return; + } + int count = s.spans * 2; + LongBuffer values = this.results; + values.clear(); + values.limit(count); + int result = VK12.vkGetQueryPoolResults(this.device, s.pool, 0, count, values, 8L, QUERY_RESULT_64_BIT); + if (result != VK12.VK_SUCCESS) { + skipped(); + return; + } + long frame = 0L; + long uploads = 0L; + Arrays.fill(this.passNs, 0L); + double tick = period; + for (int i = 0; i < s.spans; i++) { + long ticks = (values.get(2 * i + 1) - values.get(2 * i)) & validMask; + long ns = ticks < 0L ? 0L : (long) (ticks * tick); + int kind = s.kinds[i]; + if (kind == KIND_FRAME) { + frame += ns; + } else if (kind == KIND_UPLOADS) { + frame += ns; + uploads += ns; + } else { + this.passNs[kind] += ns; + } + } + measured(frame, uploads, this.passNs); + } + + /** Reserves a span (two queries) and writes its start, or returns -1 when nothing is measured or it is full. */ + private int begin(VkCommandBuffer commandBuffer, int kind) { + Slot s = this.current; + if (s == null) { + return -1; + } + if (s.spans * 2 + 2 > s.size) { + s.overflow = true; + return -1; + } + int span = s.spans++; + s.kinds[span] = kind; + KHRSynchronization2.vkCmdWriteTimestamp2KHR(commandBuffer, STAGE_ALL_COMMANDS, s.pool, span * 2); + return span; + } + + private void end(VkCommandBuffer commandBuffer, int span) { + Slot s = this.current; + if (s != null) { + KHRSynchronization2.vkCmdWriteTimestamp2KHR(commandBuffer, STAGE_ALL_COMMANDS, s.pool, span * 2 + 1); + } + } + + /** Right before vkCmdBeginRenderPass2 (outside the pass). */ + public void passBegin(VkCommandBuffer commandBuffer, @Nullable Supplier label) { + if (this.current == null || this.passSpan >= 0) { + return; + } + this.passSpan = begin(commandBuffer, labelId(label)); + } + + /** Right after vkCmdEndRenderPass2 (outside the pass). */ + public void passEnd(VkCommandBuffer commandBuffer) { + if (this.passSpan >= 0) { + end(commandBuffer, this.passSpan); + this.passSpan = -1; + } + } + + /** A command buffer of the frame was just begun: the main one, or one with uploads. */ + public void bufferBegin(VkCommandBuffer commandBuffer, boolean uploads) { + if (this.current == null || this.openCount == this.openBuffers.length) { + return; + } + int span = begin(commandBuffer, uploads ? KIND_UPLOADS : KIND_FRAME); + if (span >= 0) { + this.openBuffers[this.openCount] = commandBuffer.address(); + this.openSpans[this.openCount] = span; + this.openCount++; + } + } + + /** Right before vkEndCommandBuffer of any command buffer; only those begun while measuring get a timestamp. */ + public void bufferEnd(VkCommandBuffer commandBuffer) { + long address = commandBuffer.address(); + for (int i = 0; i < this.openCount; i++) { + if (this.openBuffers[i] == address) { + end(commandBuffer, this.openSpans[i]); + this.openCount--; + this.openBuffers[i] = this.openBuffers[this.openCount]; + this.openSpans[i] = this.openSpans[this.openCount]; + return; + } + } + } + + /** + * Gives the pools to the encoder's deferred destroy queue (the frame in flight may still write to one; the + * queue destroys them two submits later, or after the GPU went idle when the device closes). + */ + void release(VulkanCommandEncoder encoder) { + this.current = null; + this.passSpan = -1; + this.openCount = 0; + for (Slot s : this.slots) { + long pool = s.pool; + if (pool != 0L) { + VkDevice vkDevice = this.device; + encoder.queueForDestroy(() -> VK12.vkDestroyQueryPool(vkDevice, pool, null)); + } + s.pool = 0L; + s.recorded = false; + } + } + + /** Device closing (VulkanCommandEncoder.destroy). */ + public void close(VulkanCommandEncoder encoder) { + release(encoder); + resetWindow(); + } + + private static LongBuffer directLongs(int n) { + return ByteBuffer.allocateDirect(n * Long.BYTES).order(ByteOrder.nativeOrder()).asLongBuffer(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/IdleFps.java b/src/main/java/it/cosimo/vesuvio/frame/IdleFps.java new file mode 100644 index 0000000..000861e --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/IdleFps.java @@ -0,0 +1,206 @@ +package it.cosimo.vesuvio.frame; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; + +import com.mojang.blaze3d.platform.Window; + +import it.cosimo.vesuvio.Vesuvio; +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.ui.VesuvioStats; +import net.minecraft.client.FramerateLimiter; +import net.minecraft.client.Minecraft; + +/** + * Less work while nobody is looking (idea from Dynamic FPS by juliand665 and LostLuma, MIT; own code). + *

    + *
  • Window open but not focused: the frame rate is capped at {@link #LIMIT} (vanilla keeps the full rate).
  • + *
  • Window minimized: no frames are drawn, except one a second (vanilla draws 10 a second). The loop keeps + * running 20 times a second, so ticks, network packets and sounds go on as usual; the one frame a second keeps + * vanilla's per-frame work alive, including "pause on lost focus".
  • + *
  • Window hidden or fully covered ({@link #HIDDEN}): the same as minimized. SDL keeps the window's "occluded" and + * "hidden" flags (macOS reports Cmd+H, another Space and windows covering it completely; other systems report what + * they can); they are read once per loop. The flag must stay set for {@link #HIDDEN_CONFIRM_NS} of loops in a row + * before it counts, each loop crediting at most {@link #HIDDEN_LOOP_CREDIT_NS}: events are polled at the start of + * every loop, so a flag that a long stall left behind (Wayland can report a visible window as occluded while a world + * loads) gets several polls to clear before the frame rate drops.
  • + *
  • Single player paused with a menu open ({@link #PAUSED}): capped at {@link #PAUSED_LIMIT}; the world behind the + * menu stands still anyway.
  • + *
+ * Focused play is never touched. Coming back is immediate: the next loop sees the focus, restore, expose or unpause + * and runs at full rate (the wait in between is at most one idle frame, 1/limit of a second, or 50 ms when minimized + * or hidden). + */ +public final class IdleFps { + public static final VesuvioConfig.Toggle SETTING = VesuvioConfig.toggle("frame", "idleFps", true, false); + /** At least 3: vanilla runs at most 10 client ticks per frame, so fewer than 2 fps would slow the client down. */ + public static final VesuvioConfig.Number LIMIT = VesuvioConfig.number("frame", "idleFpsLimit", 3, 60, 10, false); + /** A hidden or fully covered window counts as minimized. */ + public static final VesuvioConfig.Toggle HIDDEN = VesuvioConfig.toggle("frame", "hiddenWindowFps", true, false); + /** Cap while single player is paused behind a menu. */ + public static final VesuvioConfig.Toggle PAUSED = VesuvioConfig.toggle("frame", "pausedFps", true, false); + public static final VesuvioConfig.Number PAUSED_LIMIT = VesuvioConfig.number("frame", "pausedFpsLimit", 10, 240, 60, + false); + + /** Loop rate while minimized: 20 a second keeps one client tick per loop (vanilla runs at most 10 a loop). */ + private static final int MINIMIZED_LOOP_RATE = 20; + /** While minimized, still draw one real frame this often. */ + private static final long MINIMIZED_FRAME_INTERVAL_NS = 1_000_000_000L; + /** Frame cap for the one real frame a second while hidden (vanilla's own cap for a minimized window). */ + private static final int HIDDEN_LIMIT = 10; + /** How long the hidden or occluded flag must last, in loop time (see {@link #HIDDEN_LOOP_CREDIT_NS}). */ + private static final long HIDDEN_CONFIRM_NS = 500_000_000L; + /** + * Most of one loop's time that counts towards {@link #HIDDEN_CONFIRM_NS}: a loop that stalled (a world loading) + * polled events only once, so it counts as a short one. At least five polls see the flag before it counts. + */ + private static final long HIDDEN_LOOP_CREDIT_NS = 100_000_000L; + + public static final int FULL = 0; + public static final int UNFOCUSED = 1; + public static final int MINIMIZED = 2; + public static final int HIDDEN_WINDOW = 3; + public static final int PAUSED_MENU = 4; + + /** Why the frame rate is capped: {@link #FULL}, {@link #UNFOCUSED}, ... For the settings screen and F3. */ + public static int state; + /** Frames skipped while minimized or hidden since start-up. */ + public static long skippedFrames; + + private static long lastFrameNs = System.nanoTime(); + private static boolean skipping; + /** The window was hidden or fully covered at the start of this loop, for long enough (see beginLoop). */ + private static boolean hidden; + /** SDL reported the window hidden or occluded at the start of the last loop. */ + private static boolean hiddenFlagLastLoop; + /** Loop time the flag has been set in a row, each loop counted up to HIDDEN_LOOP_CREDIT_NS. */ + private static long hiddenForNs; + private static long lastLoopNs = System.nanoTime(); + + private IdleFps() { + } + + public static void init() { + // Touching the class creates the settings. + VesuvioStats.register("frame cap reason", () -> state); + VesuvioStats.register("idle frames skipped", () -> (int) Math.min(Integer.MAX_VALUE, skippedFrames)); + } + + /** + * FramerateLimitTracker.getFramerateLimit: vanilla's limit, capped while unfocused, hidden or paused (not during the + * benchmark, which may run with the window watched from another screen). + */ + public static int limit(int vanilla, Minecraft minecraft) { + Window window = minecraft.getWindow(); + boolean benchmark = it.cosimo.vesuvio.ui.VesuvioBenchmark.running(); + boolean idle = SETTING.on() && !benchmark; + if (idle && window.isIconified()) { + state = MINIMIZED; + return vanilla; + } + if (hidden && HIDDEN.on() && !benchmark) { + state = HIDDEN_WINDOW; + return Math.min(vanilla, HIDDEN_LIMIT); + } + if (idle && !window.isFocused()) { + state = UNFOCUSED; + return Math.min(vanilla, LIMIT.get()); + } + if (PAUSED.on() && !benchmark && minecraft.isPaused() && minecraft.gui.screen() != null) { + state = PAUSED_MENU; + return Math.min(vanilla, PAUSED_LIMIT.get()); + } + state = FULL; + return vanilla; + } + + /** + * Minecraft.runTick, start: decide whether this loop draws. While minimized or hidden, a loop that does not draw + * also skips the texture-animation tick, as vanilla does one animation tick per drawn frame at most (so no GPU work + * piles up between submits). + */ + public static void beginLoop(Window window) { + long now = System.nanoTime(); + boolean flag = HIDDEN.on() && !it.cosimo.vesuvio.ui.VesuvioBenchmark.running() && WindowFlags.hiddenOrOccluded(window); + if (!flag) { + hiddenForNs = 0L; + } else if (hiddenFlagLastLoop) { + hiddenForNs += Math.min(now - lastLoopNs, HIDDEN_LOOP_CREDIT_NS); + } + hiddenFlagLastLoop = flag; + lastLoopNs = now; + hidden = flag && hiddenForNs >= HIDDEN_CONFIRM_NS; + boolean away = SETTING.on() && window.isIconified() || hidden; + skipping = away && now - lastFrameNs < MINIMIZED_FRAME_INTERVAL_NS; + if (skipping) { + state = hidden && !window.isIconified() ? HIDDEN_WINDOW : MINIMIZED; + } + } + + /** True when this loop draws nothing. */ + public static boolean skipping() { + return skipping; + } + + /** Minecraft.runTick, instead of renderFrame: wait as a frame would (vanilla's limiter, continuous pacing). */ + public static void skipFrame() { + skippedFrames++; + FramerateLimiter.limitDisplayFPS(MINIMIZED_LOOP_RATE); + } + + public static void frameRendered() { + lastFrameNs = System.nanoTime(); + } + + /** + * SDL_GetWindowFlags, looked up at run time (LWJGL's SDL binding ships with the game). Render thread, which is the + * thread that owns the window. Any failure means "visible", which is vanilla's behaviour. + */ + static final class WindowFlags { + /** SDL3: SDL_WINDOW_OCCLUDED 0x4, SDL_WINDOW_HIDDEN 0x8 (read from LWJGL when it has them). */ + private static final long OCCLUDED = constant("SDL_WINDOW_OCCLUDED", 0x4L); + private static final long HIDDEN_FLAG = constant("SDL_WINDOW_HIDDEN", 0x8L); + private static final MethodHandle GET_FLAGS = find(); + private static boolean failed; + + private WindowFlags() { + } + + private static Class video() throws ClassNotFoundException { + return Class.forName("org.lwjgl.sdl.SDLVideo", false, IdleFps.class.getClassLoader()); + } + + private static long constant(String name, long fallback) { + try { + return video().getField(name).getLong(null); + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + return fallback; + } + } + + private static MethodHandle find() { + try { + return MethodHandles.publicLookup().findStatic(video(), "SDL_GetWindowFlags", + MethodType.methodType(long.class, long.class)); + } catch (ReflectiveOperationException | LinkageError | RuntimeException e) { + Vesuvio.LOGGER.info("Vesuvio: window flags not readable, hidden windows are treated as visible"); + return null; + } + } + + static boolean hiddenOrOccluded(Window window) { + if (GET_FLAGS == null || failed) { + return false; + } + try { + long flags = (long) GET_FLAGS.invokeExact(window.handle()); + return (flags & (OCCLUDED | HIDDEN_FLAG)) != 0L; + } catch (Throwable t) { + failed = true; + Vesuvio.LOGGER.warn("Vesuvio: reading the window flags failed, hidden windows are treated as visible", t); + return false; + } + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/MappedVertexPools.java b/src/main/java/it/cosimo/vesuvio/frame/MappedVertexPools.java new file mode 100644 index 0000000..9a26446 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/MappedVertexPools.java @@ -0,0 +1,193 @@ +package it.cosimo.vesuvio.frame; + +import java.nio.ByteBuffer; +import java.util.List; + +import org.lwjgl.system.MemoryStack; +import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkMemoryHeap; +import org.lwjgl.vulkan.VkMemoryType; +import org.lwjgl.vulkan.VkPhysicalDeviceMemoryProperties; + +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.VertexSorting; +import com.mojang.renderpearl.api.buffers.GpuBuffer; +import com.mojang.renderpearl.api.buffers.GpuBufferSlice; + +import it.cosimo.vesuvio.Vesuvio; +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.frame.mixin.FrameStagedDrawAccessor; +import it.cosimo.vesuvio.frame.mixin.FrameStagedVertexBufferInvoker; +import it.cosimo.vesuvio.ui.VesuvioStats; +import net.minecraft.client.renderer.StagedVertexBuffer; + +/** + * Setting mappedVertexPools (research-vulkan-engine.md, "Smaller items"): on unified memory, the vertex and index + * buffers of StagedVertexBuffer (entities, block entities, particles, the GUI) are written by the CPU directly. + * + *

Vanilla writes each upload into a mapped staging buffer and then records copyToBuffer into the vertex buffer and + * the index buffer, each followed by a full barrier: two to four GPU copies a frame, and on MoltenVK a blit encoder + * between render encoders. When the GPU's memory is the computer's memory (Apple silicon: every memory heap is device + * local and one of them has a host-visible, coherent memory type), a buffer the GPU reads as vertices can just as + * well be host-visible: the pools' new buffers get the map-write usage (the backend then picks host-visible coherent + * memory, the same kind the staging buffer uses), and the upload writes the same bytes at the same offsets into them + * directly. Nothing else changes: same buffers bound, same offsets, same draws. + * + *

Why the GPU never reads a buffer while the CPU writes it (two frames in flight). StagedVertexBuffer's + * pools already recycle buffers by fence, and the CPU writes only into a buffer the pool just handed out: + *

    + *
  1. acquire returns a new buffer (nothing ever read it) or one from the pool's available list;
  2. + *
  3. a buffer enters the available list only in tryRecycleBuffers, when the fence made at the end of the frame + * that used it reports completion. That fence holds the encoder's current submit index at that moment, and reports + * completion only once the submit timeline semaphore has reached it (vkWaitSemaphores). The semaphore is signalled at + * the end of each submit with all commands as its first scope, which also covers every earlier submit on the + * queue;
  4. + *
  5. every draw that reads the buffer is recorded between upload and endDraw, and endDraw comes before the pool's + * endFrame that makes the fence, so every read is in that submit or an earlier one;
  6. + *
  7. so when the buffer is written again, every GPU read of its previous contents has finished. The writes reach + * coherent memory before the vkQueueSubmit of the frame whose draws read them, which makes them visible to those + * draws (host writes are ordered before a submission).
  8. + *
+ * This is exactly the ownership vanilla already relies on for its own mapped staging buffers (same pool class, same + * fence), which the CPU writes and the GPU reads in the same way; only the reader changes from a copy to the draws. + * + *

Live: with the setting or the master switch off, new pool buffers are made as vanilla's and every upload goes + * through the staging copy. A buffer made the other way that a pool hands out is closed and replaced at once (it is + * idle: it came from the available list), so the pools turn over within a frame. + */ +public final class MappedVertexPools { + /** Write StagedVertexBuffer's vertex and index buffers directly on unified memory. Live. */ + public static final VesuvioConfig.Toggle SETTING = VesuvioConfig.toggle("frame", "mappedVertexPools", true, false); + + /** Set once the Vulkan device exists: every heap device local, one with a host-visible coherent type. */ + private static volatile boolean unified; + /** "unified memory", "not unified", or "no device yet": for the log and the report. */ + public static volatile String status = "no device yet"; + + private static int skipped; + private static volatile int skippedLastFrame; + + private MappedVertexPools() { + } + + public static void init() { + VesuvioStats.register("vertex copies skipped", () -> skippedLastFrame); + } + + /** VulkanDevice constructor: decides whether memory is unified (heaps and types, not the OS). */ + public static void deviceCreated(VkDevice vkDevice) { + boolean result = false; + try (MemoryStack stack = MemoryStack.stackPush()) { + VkPhysicalDeviceMemoryProperties props = VkPhysicalDeviceMemoryProperties.calloc(stack); + VK10.vkGetPhysicalDeviceMemoryProperties(vkDevice.getPhysicalDevice(), props); + int heaps = props.memoryHeapCount(); + boolean allDeviceLocal = heaps > 0; + for (int i = 0; i < heaps; i++) { + VkMemoryHeap heap = props.memoryHeaps(i); + if ((heap.flags() & VK10.VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) == 0) { + allDeviceLocal = false; + } + } + int wanted = VK10.VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK10.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT + | VK10.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + boolean shared = false; + for (int i = 0; i < props.memoryTypeCount(); i++) { + VkMemoryType type = props.memoryTypes(i); + if ((type.propertyFlags() & wanted) == wanted) { + shared = true; + } + } + result = allDeviceLocal && shared; + } catch (RuntimeException | LinkageError e) { + Vesuvio.LOGGER.warn("Vesuvio: could not read the GPU's memory heaps, vertex buffers stay as in vanilla", e); + } + unified = result; + status = result ? "unified memory" : "not unified"; + Vesuvio.LOGGER.info("Vesuvio: GPU memory {}: entity and GUI vertex buffers {}", status, + result ? "written directly (setting mappedVertexPools)" : "copied as in vanilla"); + } + + /** Whether the pools should hand out mappable vertex and index buffers right now. */ + private static boolean mapNow() { + return unified && SETTING.on(); + } + + /** One of the vertex or index pools (not the staging pool, which is already mappable). */ + private static boolean isDrawPool(int poolUsage) { + return (poolUsage & (GpuBuffer.USAGE_VERTEX | GpuBuffer.USAGE_INDEX)) != 0 + && (poolUsage & GpuBuffer.USAGE_MAP_WRITE) == 0; + } + + /** GpuBufferPool.acquire, creating a buffer: the usage it is made with. */ + public static int createUsage(int poolUsage) { + return isDrawPool(poolUsage) && mapNow() ? poolUsage | GpuBuffer.USAGE_MAP_WRITE : poolUsage; + } + + /** GpuBufferPool.acquire, reusing an idle buffer: false when it was made the other way (it is then replaced). */ + public static boolean reusable(int poolUsage, GpuBuffer buffer) { + if (!isDrawPool(poolUsage)) { + return true; + } + return ((buffer.usage() & GpuBuffer.USAGE_MAP_WRITE) != 0) == mapNow(); + } + + /** + * StagedVertexBuffer.uploadDrawsToBuffers: writes the draws straight into the buffers when both can be mapped, + * with the same bytes at the same offsets as vanilla's staging copy. Returns false to let vanilla run. + */ + public static boolean writeDirect(List draws, GpuBuffer vertexBuffer, + GpuBuffer indexBuffer, int vertexBufferSize, int indexBufferSize) { + if (!SETTING.on() || (vertexBuffer.usage() & GpuBuffer.USAGE_MAP_WRITE) == 0 + || indexBuffer != null && (indexBuffer.usage() & GpuBuffer.USAGE_MAP_WRITE) == 0) { + return false; + } + int n = draws.size(); + try (GpuBufferSlice.MappedView view = vertexBuffer.slice(0L, vertexBufferSize).map(false, true)) { + ByteBuffer buffer = view.data(); + for (int i = 0; i < n; i++) { + StagedVertexBuffer.Draw draw = draws.get(i); + if (!draw.isEmpty()) { + FrameStagedDrawAccessor access = (FrameStagedDrawAccessor) draw; + buffer.position(access.vesuvio$vertexOffset()); + List slices = access.vesuvio$vertexBufferSlices(); + for (int j = 0, m = slices.size(); j < m; j++) { + buffer.put(slices.get(j).byteBuffer()); + } + } + } + } + skipped++; + if (indexBuffer != null) { + try (GpuBufferSlice.MappedView view = indexBuffer.slice(0L, indexBufferSize).map(false, true)) { + ByteBuffer buffer = view.data(); + for (int i = 0; i < n; i++) { + StagedVertexBuffer.Draw draw = draws.get(i); + FrameStagedDrawAccessor access = (FrameStagedDrawAccessor) draw; + VertexSorting sorting = access.vesuvio$quadSorting(); + if (!draw.isEmpty() && sorting != null) { + MeshData.SortState sortState = new MeshData.SortState( + FrameStagedVertexBufferInvoker.vesuvio$decodeSortingPoints(draw), access.vesuvio$indexType()); + buffer.position(access.vesuvio$indexOffset()); + sortState.writeSortedIndexBuffer(buffer, sorting); + } + } + } + skipped++; + } + for (int i = 0; i < n; i++) { + StagedVertexBuffer.Draw draw = draws.get(i); + if (!draw.isEmpty()) { + ((FrameStagedDrawAccessor) draw).vesuvio$freeVertexData(); + } + } + return true; + } + + /** End of frame (submit): publish the counter. */ + public static void endFrame() { + skippedLastFrame = skipped; + skipped = 0; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/ModelSprites.java b/src/main/java/it/cosimo/vesuvio/frame/ModelSprites.java new file mode 100644 index 0000000..cbf61a5 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/ModelSprites.java @@ -0,0 +1,81 @@ +package it.cosimo.vesuvio.frame; + +import java.util.ArrayList; +import java.util.List; + +import org.jspecify.annotations.Nullable; + +import net.minecraft.client.renderer.block.dispatch.BlockStateModel; +import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart; +import net.minecraft.client.resources.model.geometry.BakedQuad; +import net.minecraft.client.renderer.texture.SpriteContents; +import net.minecraft.core.Direction; +import net.minecraft.util.RandomSource; + +/** + * The animated sprites a block model can draw, worked out once per model (vanilla's three model classes carry the + * answer, see {@link Holder}) and added to a section's set for every block the section compiles. This sits before the + * block renderer, so it works whether the quads are then written by vanilla's renderer or by Fabric API's Indigo + * (which replaces it in SectionCompiler). It is conservative: every variant and every part a model may pick counts, + * whether or not the face ends up culled. A model of any other class (from a mod) makes the section "unknown", + * which keeps every sprite animating while it is on screen. + */ +public final class ModelSprites { + private static final Direction[] FACES = { null, Direction.DOWN, Direction.UP, Direction.NORTH, Direction.SOUTH, + Direction.WEST, Direction.EAST }; + + private ModelSprites() { + } + + /** Implemented by SingleVariant, WeightedVariants and MultiPartModel (frame mixins). */ + public interface Holder { + /** The animated sprites this model can draw (computed once), or null when unknown. */ + SpriteContents @Nullable [] vesuvio$animatedSprites(); + } + + /** Section compile thread: adds what {@code model} can draw to {@code out}. */ + public static void collect(BlockStateModel model, SpriteSet out) { + if (!(model instanceof Holder holder)) { + out.markUnknown(); + return; + } + if (!model.hasMaterialFlag(BakedQuad.FLAG_ANIMATED)) { + return; + } + SpriteContents[] sprites = holder.vesuvio$animatedSprites(); + if (sprites == null) { + out.markUnknown(); + } else { + out.addAll(sprites); + } + } + + /** The animated sprites of one model part, every face. */ + public static void addPart(BlockStateModelPart part, SpriteSet out) { + for (Direction face : FACES) { + for (BakedQuad quad : part.getQuads(face)) { + out.add(quad.materialInfo().sprite()); + } + } + } + + /** A sub-model's sprites (WeightedVariants entries, MultiPartModel parts); false when it is unknown. */ + public static boolean addModel(BlockStateModel model, SpriteSet out) { + if (!(model instanceof Holder holder)) { + return false; + } + SpriteContents[] sprites = holder.vesuvio$animatedSprites(); + if (sprites == null) { + return false; + } + out.addAll(sprites); + return true; + } + + /** The parts a model picks with a fixed seed (single variants pick the same part whatever the seed). */ + public static List partsOf(BlockStateModel model) { + List parts = new ArrayList<>(); + model.collectParts(RandomSource.create(0L), parts); + return parts; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/MoltenVkSettings.java b/src/main/java/it/cosimo/vesuvio/frame/MoltenVkSettings.java new file mode 100644 index 0000000..4c102bd --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/MoltenVkSettings.java @@ -0,0 +1,198 @@ +package it.cosimo.vesuvio.frame; + +import java.nio.IntBuffer; + +import org.lwjgl.PointerBuffer; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.vulkan.EXTLayerSettings; +import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkExtensionProperties; +import org.lwjgl.vulkan.VkInstanceCreateInfo; +import org.lwjgl.vulkan.VkLayerSettingEXT; +import org.lwjgl.vulkan.VkLayerSettingsCreateInfoEXT; + +import com.mojang.renderpearl.util.PlatformUtil; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.Vesuvio; + +/** + * MoltenVK settings passed by the game itself at Vulkan instance creation, through VK_EXT_layer_settings (the same + * values as the MVK_CONFIG_* environment variables, which a launcher would otherwise have to set). macOS only, and + * only when MoltenVK offers the extension; everywhere else nothing changes. Read once at start-up. + * + *

    + *
  • {@link #FAST_COMPILE}: MVK_CONFIG_SHOULD_MAXIMIZE_CONCURRENT_COMPILATION = true. Metal compiles shaders on + * more threads at once (macOS 13.3+), so the hitch when a pipeline is first used is shorter.
  • + *
  • {@link #ASYNC_SUBMIT} (opt-in): MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS = false. MoltenVK turns the frame's + * Vulkan commands into Metal on its own thread instead of inside vkQueueSubmit on the render thread. Helps only when + * the CPU side is the limit; a less used MoltenVK path, so off by default.
  • + *
  • {@link #NO_ARGUMENT_BUFFERS} (opt-in, a benchmark experiment): MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS = 0. + * MoltenVK then binds each descriptor set's resources one by one into the Metal encoder instead of through a Metal + * argument buffer. The game writes a new descriptor set for nearly every draw, so either way may encode faster; the + * sign is unknown and only a benchmark can tell. Without argument buffers Metal allows at most 16 samplers per shader + * stage, which a shader pack with many textures can exceed. The game itself does not need descriptor indexing (not in + * VulkanFeatureSets), so the device is created as usual.
  • + *
+ */ +public final class MoltenVkSettings { + public static final VesuvioConfig.Toggle FAST_COMPILE = VesuvioConfig.toggle("frame", "fastStartupCompile", true, true); + public static final VesuvioConfig.Toggle ASYNC_SUBMIT = VesuvioConfig.toggle("frame", "asyncSubmit", false, true); + public static final VesuvioConfig.Toggle NO_ARGUMENT_BUFFERS = VesuvioConfig.toggle("frame", "metalArgumentBuffersOff", + false, true); + + private static final String EXTENSION = "VK_EXT_layer_settings"; + private static final String LAYER = "MoltenVK"; + + /** What was applied at start-up, for the settings screen: "off", "not macOS", "no extension", or the list. */ + public static String status = "not applied"; + + private MoltenVkSettings() { + } + + public static void init() { + // Touching the class creates the settings. + } + + /** VulkanInstance constructor, around vkCreateInstance. Render thread, once. */ + public static int createInstance(VkInstanceCreateInfo info, Creator create) { + boolean fastCompile = FAST_COMPILE.on(); + boolean asyncSubmit = ASYNC_SUBMIT.on(); + boolean noArgumentBuffers = NO_ARGUMENT_BUFFERS.on(); + if (!fastCompile && !asyncSubmit && !noArgumentBuffers) { + status = "off"; + return create.create(info); + } + if (!PlatformUtil.IS_MACOS) { + status = "not macOS"; + return create.create(info); + } + try { + return createWithSettings(info, create, fastCompile, asyncSubmit, noArgumentBuffers); + } catch (LinkageError | RuntimeException e) { + Vesuvio.LOGGER.warn("Vesuvio: could not pass MoltenVK settings, creating the instance as usual", e); + status = "failed"; + return create.create(info); + } + } + + private static int createWithSettings(VkInstanceCreateInfo info, Creator create, boolean fastCompile, + boolean asyncSubmit, boolean noArgumentBuffers) { + if (!hasInstanceExtension(EXTENSION)) { + status = "no extension"; + Vesuvio.LOGGER.info("Vesuvio: Vulkan driver has no {}, MoltenVK settings not passed", EXTENSION); + return create.create(info); + } + long oldNext = info.pNext(); + PointerBuffer oldExtensions = info.ppEnabledExtensionNames(); + try (MemoryStack stack = MemoryStack.stackPush()) { + int oldCount = oldExtensions == null ? 0 : oldExtensions.remaining(); + boolean alreadyEnabled = false; + for (int i = 0; i < oldCount; i++) { + if (EXTENSION.equals(oldExtensions.getStringUTF8(i))) { + alreadyEnabled = true; + } + } + PointerBuffer extensions = stack.mallocPointer(oldCount + (alreadyEnabled ? 0 : 1)); + for (int i = 0; i < oldCount; i++) { + extensions.put(i, oldExtensions.get(i)); + } + if (!alreadyEnabled) { + extensions.put(oldCount, stack.UTF8(EXTENSION)); + } + + int count = (fastCompile ? 1 : 0) + (asyncSubmit ? 1 : 0) + (noArgumentBuffers ? 1 : 0); + VkLayerSettingEXT.Buffer settings = VkLayerSettingEXT.calloc(count, stack); + int i = 0; + StringBuilder applied = new StringBuilder(); + if (fastCompile) { + bool(settings.get(i++), stack, "MVK_CONFIG_SHOULD_MAXIMIZE_CONCURRENT_COMPILATION", true); + applied.append("concurrent compilation"); + } + if (asyncSubmit) { + bool(settings.get(i++), stack, "MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS", false); + applied.append(applied.isEmpty() ? "" : ", ").append("asynchronous submits"); + } + if (noArgumentBuffers) { + // MVKUseMetalArgumentBuffers is a 32-bit enum; 0 = MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS_NEVER. + uint(settings.get(i++), stack, "MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS", 0); + applied.append(applied.isEmpty() ? "" : ", ").append("no Metal argument buffers"); + } + VkLayerSettingsCreateInfoEXT layerSettings = VkLayerSettingsCreateInfoEXT.calloc(stack) + .sType$Default() + .pNext(oldNext) + .pSettings(settings); + + info.ppEnabledExtensionNames(extensions); + info.pNext(layerSettings.address()); + int result; + try { + result = create.create(info); + } finally { + // The game keeps using info (VkInstance reads its extension list): give it back its own memory. + info.pNext(oldNext); + info.ppEnabledExtensionNames(oldExtensions); + } + if (result == VK10.VK_SUCCESS) { + status = applied.toString(); + Vesuvio.LOGGER.info("Vesuvio: MoltenVK settings passed: {}", status); + return result; + } + Vesuvio.LOGGER.warn("Vesuvio: vkCreateInstance with MoltenVK settings returned {}, retrying without", + result); + status = "failed"; + return create.create(info); + } + } + + private static void bool(VkLayerSettingEXT setting, MemoryStack stack, String name, boolean value) { + IntBuffer v = stack.ints(value ? VK10.VK_TRUE : VK10.VK_FALSE); + setting.pLayerName(stack.UTF8(LAYER)) + .pSettingName(stack.UTF8(name)) + .type(EXTLayerSettings.VK_LAYER_SETTING_TYPE_BOOL32_EXT) + .pValues(MemoryUtil.memByteBuffer(v)) + // After pValues: the count is in values, not bytes. + .valueCount(1); + } + + private static void uint(VkLayerSettingEXT setting, MemoryStack stack, String name, int value) { + IntBuffer v = stack.ints(value); + setting.pLayerName(stack.UTF8(LAYER)) + .pSettingName(stack.UTF8(name)) + .type(EXTLayerSettings.VK_LAYER_SETTING_TYPE_UINT32_EXT) + .pValues(MemoryUtil.memByteBuffer(v)) + // After pValues: the count is in values, not bytes. + .valueCount(1); + } + + private static boolean hasInstanceExtension(String name) { + try (MemoryStack stack = MemoryStack.stackPush()) { + IntBuffer count = stack.callocInt(1); + if (VK10.vkEnumerateInstanceExtensionProperties((String) null, count, null) != VK10.VK_SUCCESS) { + return false; + } + // On the heap: 260 bytes per extension, and with many layers installed the list can outgrow LWJGL's stack. + VkExtensionProperties.Buffer props = VkExtensionProperties.calloc(count.get(0)); + try { + if (VK10.vkEnumerateInstanceExtensionProperties((String) null, count, props) != VK10.VK_SUCCESS) { + return false; + } + for (int i = 0; i < count.get(0); i++) { + if (name.equals(props.get(i).extensionNameString())) { + return true; + } + } + return false; + } finally { + props.free(); + } + } + } + + /** The game's own vkCreateInstance call. */ + @FunctionalInterface + public interface Creator { + int create(VkInstanceCreateInfo info); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/PipelineDiskCache.java b/src/main/java/it/cosimo/vesuvio/frame/PipelineDiskCache.java new file mode 100644 index 0000000..cd1c115 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/PipelineDiskCache.java @@ -0,0 +1,206 @@ +package it.cosimo.vesuvio.frame; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.LongBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Locale; + +import org.lwjgl.PointerBuffer; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkPhysicalDeviceProperties; +import org.lwjgl.vulkan.VkPipelineCacheCreateInfo; + +import it.cosimo.vesuvio.Vesuvio; +import it.cosimo.vesuvio.VesuvioConfig; +import net.fabricmc.loader.api.FabricLoader; + +/** + * Setting pipelineCache. Vanilla compiles every render pipeline (shader stages plus fixed state) with no Vulkan + * pipeline cache (VK_NULL_HANDLE in vkCreateGraphicsPipelines), so the driver redoes all of its work on every + * launch, and the first time a pipeline shows up in a world (a new mob, effect or shader pack pass) the frame waits + * for it. This keeps one VkPipelineCache for the device, saves it to {@code vesuvio-cache/} in the game folder when + * the game closes (and every minute while new pipelines appear), and gives it back to the driver at the next launch. + * The driver checks the file's header (vendor, device, cache UUID, which changes with the driver) and ignores data + * that does not match; Vesuvio checks the same header first and starts empty on any mismatch or read error. + * + *

Pictures do not change: a pipeline cache only stores what the driver already produced for the same input. On + * Windows drivers this removes most first-use shader hitches after the first launch; on macOS MoltenVK keeps its + * SPIR-V to Metal translation in it (Metal keeps its own compiled cache), so the gain there is smaller. + */ +public final class PipelineDiskCache { + public static final VesuvioConfig.Toggle ENABLED = VesuvioConfig.toggle("frame", "pipelineCache", true, true); + + private static final int HEADER = 32; + /** Larger saved caches are ignored (and not written): real ones are a few MB. */ + private static final long MAX_BYTES = 64L * 1024 * 1024; + private static final long SAVE_EVERY_NS = 60_000_000_000L; + + private static long cache; + private static VkDevice device; + private static Path file; + private static long lastSave; + private static volatile int created; + private static int createdAtSave; + /** Pipelines compiled and total time spent in vkCreateGraphicsPipelines this session, for the counters. */ + public static volatile int compiles; + public static volatile long compileNs; + public static String status = "not started"; + + private PipelineDiskCache() { + } + + public static void init() { + it.cosimo.vesuvio.ui.VesuvioStats.register("pipeline compiles", () -> compiles); + it.cosimo.vesuvio.ui.VesuvioStats.register("pipeline compile ms", () -> (int) (compileNs / 1_000_000L)); + } + + /** VulkanDevice constructor, while the physical device's properties are still readable. Render thread, once. */ + public static void open(VkDevice vkDevice, VkPhysicalDeviceProperties props) { + if (!ENABLED.on() || cache != 0L) { + status = cache != 0L ? status : "off"; + return; + } + try { + file = FabricLoader.getInstance().getGameDir().resolve("vesuvio-cache").resolve(String.format(Locale.ROOT, + "pipelines-%04x-%04x.bin", props.vendorID(), props.deviceID())); + byte[] data = read(file, props); + // The saved data goes in native heap memory: it can be megabytes, far more than LWJGL's small per-thread + // stack holds (a 3.5 MB cache on the stack crashed 0.3.0 at launch). + ByteBuffer initial = data == null ? null : MemoryUtil.memAlloc(data.length).put(data).flip(); + try (MemoryStack stack = MemoryStack.stackPush()) { + long handle = create(vkDevice, initial, stack); + if (handle == 0L && initial != null) { + Vesuvio.LOGGER.info("Vesuvio: the driver refused the saved pipeline cache, starting a new one"); + handle = create(vkDevice, null, stack); + data = null; + } + cache = handle; + } finally { + if (initial != null) { + MemoryUtil.memFree(initial); + } + } + device = vkDevice; + lastSave = System.nanoTime(); + status = cache == 0L ? "failed" : data == null ? "new" : "loaded " + data.length / 1024 + " KB"; + Vesuvio.LOGGER.info("Vesuvio pipeline cache: {} ({})", status, file.getFileName()); + } catch (Throwable e) { + // Anything at all (also an Error from native memory): the game starts without a cache, as vanilla does. + if (cache != 0L) { + try { + VK10.vkDestroyPipelineCache(vkDevice, cache, null); + } catch (Throwable ignored) { + // Nothing more to do: the handle is dropped. + } + } + cache = 0L; + device = null; + status = "failed"; + Vesuvio.LOGGER.warn("Vesuvio: pipeline cache not used", e); + } + } + + private static long create(VkDevice vkDevice, ByteBuffer initial, MemoryStack stack) { + VkPipelineCacheCreateInfo info = VkPipelineCacheCreateInfo.calloc(stack).sType$Default(); + if (initial != null) { + info.pInitialData(initial); + } + LongBuffer out = stack.callocLong(1); + return VK10.vkCreatePipelineCache(vkDevice, info, null, out) == VK10.VK_SUCCESS ? out.get(0) : 0L; + } + + /** The cache to pass to vkCreateGraphicsPipelines instead of VK_NULL_HANDLE (0 when not used). */ + public static long handle(VkDevice vkDevice) { + return vkDevice == device ? cache : 0L; + } + + /** After every pipeline compile (any thread). */ + public static void compiled(long ns) { + created++; + compiles++; + compileNs += ns; + } + + /** End of a client tick: saves once a minute if pipelines were compiled since the last save. */ + public static void maybeSave() { + if (cache == 0L || created == createdAtSave) { + return; + } + long now = System.nanoTime(); + if (now - lastSave >= SAVE_EVERY_NS) { + lastSave = now; + save(); + } + } + + /** VulkanDevice.close, before the device goes: saves and destroys the cache. */ + public static void close(VkDevice vkDevice) { + if (cache == 0L || vkDevice != device) { + return; + } + save(); + VK10.vkDestroyPipelineCache(device, cache, null); + cache = 0L; + device = null; + } + + private static void save() { + int count = created; + try (MemoryStack stack = MemoryStack.stackPush()) { + PointerBuffer size = stack.mallocPointer(1); + if (VK10.vkGetPipelineCacheData(device, cache, size, null) != VK10.VK_SUCCESS || size.get(0) <= HEADER + || size.get(0) > MAX_BYTES) { + return; + } + ByteBuffer data = MemoryUtil.memAlloc((int) size.get(0)); + try { + // The size can only grow between the two calls; VK_INCOMPLETE then means a truncated, unusable copy. + if (VK10.vkGetPipelineCacheData(device, cache, size, data) != VK10.VK_SUCCESS) { + return; + } + byte[] bytes = new byte[(int) size.get(0)]; + data.get(0, bytes); + Files.createDirectories(file.getParent()); + Path tmp = file.resolveSibling(file.getFileName() + ".tmp"); + Files.write(tmp, bytes); + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + createdAtSave = count; + } finally { + MemoryUtil.memFree(data); + } + } catch (Throwable e) { + Vesuvio.LOGGER.warn("Vesuvio: could not save the pipeline cache", e); + } + } + + /** The saved cache if its header matches this device and driver, else null. */ + private static byte[] read(Path path, VkPhysicalDeviceProperties props) { + try { + if (!Files.isRegularFile(path) || Files.size(path) <= HEADER || Files.size(path) > MAX_BYTES) { + return null; + } + byte[] data = Files.readAllBytes(path); + ByteBuffer b = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); + if (b.getInt(0) < HEADER || b.getInt(4) != VK10.VK_PIPELINE_CACHE_HEADER_VERSION_ONE + || b.getInt(8) != props.vendorID() || b.getInt(12) != props.deviceID()) { + return null; + } + ByteBuffer uuid = props.pipelineCacheUUID(); + for (int i = 0; i < VK10.VK_UUID_SIZE; i++) { + if (data[16 + i] != uuid.get(i)) { + return null; // another driver version + } + } + return data; + } catch (Throwable e) { + Vesuvio.LOGGER.info("Vesuvio: saved pipeline cache unreadable, starting a new one ({})", e.toString()); + return null; + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/SpriteCollectorHolder.java b/src/main/java/it/cosimo/vesuvio/frame/SpriteCollectorHolder.java new file mode 100644 index 0000000..5e4b72c --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/SpriteCollectorHolder.java @@ -0,0 +1,11 @@ +package it.cosimo.vesuvio.frame; + +import org.jspecify.annotations.Nullable; + +/** Added to SectionCompiler.Results: the animated sprites a section compile meets. */ +public interface SpriteCollectorHolder { + @Nullable + SpriteSet vesuvio$sprites(); + + void vesuvio$setSprites(@Nullable SpriteSet sprites); +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/SpriteContentsExt.java b/src/main/java/it/cosimo/vesuvio/frame/SpriteContentsExt.java new file mode 100644 index 0000000..bbe6822 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/SpriteContentsExt.java @@ -0,0 +1,12 @@ +package it.cosimo.vesuvio.frame; + +/** Added to SpriteContents: when it was last seen, and whether it belongs to a loaded atlas animation. */ +public interface SpriteContentsExt { + int vesuvio$seen(); + + void vesuvio$setSeen(int tick); + + boolean vesuvio$live(); + + void vesuvio$setLive(boolean live); +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/SpriteSet.java b/src/main/java/it/cosimo/vesuvio/frame/SpriteSet.java new file mode 100644 index 0000000..0978af1 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/SpriteSet.java @@ -0,0 +1,57 @@ +package it.cosimo.vesuvio.frame; + +import net.minecraft.client.renderer.texture.SpriteContents; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; + +/** A small identity set of animated sprites, filled by one compile worker (a section uses a handful at most). */ +public final class SpriteSet { + static final SpriteContents[] NONE = new SpriteContents[0]; + private SpriteContents[] items = NONE; + private int size; + private boolean unknown; + + /** Something in the section may draw sprites this set cannot list: the mesh will keep every sprite animating. */ + public void markUnknown() { + unknown = true; + } + + public void addAll(SpriteContents[] sprites) { + for (SpriteContents contents : sprites) { + addContents(contents); + } + } + + public void add(TextureAtlasSprite sprite) { + addContents(sprite.contents()); + } + + private void addContents(SpriteContents contents) { + if (!contents.isAnimated()) { + return; + } + for (int i = 0; i < size; i++) { + if (items[i] == contents) { + return; + } + } + if (size == items.length) { + SpriteContents[] grown = new SpriteContents[Math.max(4, size * 2)]; + System.arraycopy(items, 0, grown, 0, size); + items = grown; + } + items[size++] = contents; + } + + /** The sprites, or null when {@link #markUnknown() unknown}. */ + public SpriteContents[] toArray() { + if (unknown) { + return null; + } + if (size == 0) { + return NONE; + } + SpriteContents[] out = new SpriteContents[size]; + System.arraycopy(items, 0, out, 0, size); + return out; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/TransferBarriers.java b/src/main/java/it/cosimo/vesuvio/frame/TransferBarriers.java new file mode 100644 index 0000000..657d1c3 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/TransferBarriers.java @@ -0,0 +1,220 @@ +package it.cosimo.vesuvio.frame; + +import it.cosimo.vesuvio.VesuvioConfig; + +/** + * Setting mergeTransferBarriers. The Vulkan backend follows every copy and buffer write with a full memory barrier + * (all commands to all commands, every read and write). On MoltenVK each barrier outside a render pass also closes + * the Metal blit encoder and makes the next one wait, so a frame that sends a few hundred chunk buffers records a few + * hundred barriers and encoders; the per-frame uniform writes and entity/GUI vertex copies add 10 to 20 more. + * + *

Two copies only need a barrier between them when one touches what the other wrote, or writes what the other + * read. So the barrier after a copy or buffer write (writeToBuffer, copyToBuffer, writeToTexture, + * copyBufferToTexture) is held back: the next copy of that kind that touches none of the held ranges runs without + * one, and exactly one barrier, the same full barrier, is recorded before anything else: a render pass, a clear, a + * texture read-back or texture-to-texture copy, a timestamp, the end of the command buffer (submit, present, + * semaphores), or a copy that overlaps a held range. Pass-end and clear barriers are left as they are. + * + *

Why the picture is the same: a pipeline barrier orders everything recorded before it (in submission order, not + * only since the previous barrier) against everything after it. Every command that is not one of these copies still + * has a full barrier between it and every copy before it, as in vanilla. Between two copies inside one held group + * there is no barrier, but they share no memory written by either (buffer byte ranges, image rectangles of one mip + * and layer), so there is no hazard for a barrier to resolve. The report (report-v020-uploads.md) has the long form. + */ +public final class TransferBarriers { + /** Merge the barriers between back-to-back copies that touch different memory. Live. */ + public static final VesuvioConfig.Toggle SETTING = VesuvioConfig.toggle("frame", "mergeTransferBarriers", true, false); + + private static volatile int mergedLastFrame; + private static long mergedTotal; + + private TransferBarriers() { + } + + public static void init() { + it.cosimo.vesuvio.ui.VesuvioStats.register("upload barriers merged", () -> mergedLastFrame); + } + + /** Counter: barriers not recorded in the last submitted frame. */ + public static int mergedLastFrame() { + return mergedLastFrame; + } + + /** Counter: barriers not recorded since start-up. */ + public static long mergedTotal() { + return mergedTotal; + } + + /** + * The held barrier of one command encoder and what the copies since the last barrier touched (render thread + * only, like the encoder itself). Ranges are [start, end) in bytes per VkBuffer; image writes are rectangles per + * VkImage, mip level and layer. The lists are bounded: when one is full the barrier is recorded and they start + * over, so the overlap check stays a short scan. + */ + public static final class Tracker { + static final int MAX = 256; + + /** True while a copy's barrier is held back. */ + public boolean pending; + /** True between the start of a copy whose barrier will be held and the barrier call it replaces. */ + public boolean defer; + private int merged; + + private final long[] writes = new long[MAX * 3]; + private int writeCount; + private final long[] reads = new long[MAX * 3]; + private int readCount; + private final long[] images = new long[MAX * 6]; + private int imageCount; + + // The copy being recorded now. + private boolean hasSrc; + private long srcBuffer; + private long srcStart; + private long srcEnd; + private boolean hasDst; + private long dstBuffer; + private long dstStart; + private long dstEnd; + private boolean hasImage; + private long image; + private long subresource; + private long rectLow; + private long rectHigh; + + /** + * Start of a copy: whether its barrier will be held back (setting on). When false the caller records any + * held barrier first and the copy runs exactly as vanilla, with its own barrier. + */ + public boolean begin() { + this.defer = SETTING.on(); + this.hasSrc = false; + this.hasDst = false; + this.hasImage = false; + return this.defer; + } + + public void source(long buffer, long start, long end) { + this.hasSrc = true; + this.srcBuffer = buffer; + this.srcStart = start; + this.srcEnd = end; + } + + public void destination(long buffer, long start, long end) { + this.hasDst = true; + this.dstBuffer = buffer; + this.dstStart = start; + this.dstEnd = end; + } + + public void destinationImage(long image, int mipLevel, int layer, int x, int y, int width, int height) { + this.hasImage = true; + this.image = image; + this.subresource = ((long) mipLevel << 32) | (layer & 0xFFFFFFFFL); + this.rectLow = ((long) x << 32) | (y & 0xFFFFFFFFL); + this.rectHigh = ((long) (x + width) << 32) | ((y + height) & 0xFFFFFFFFL); + } + + /** + * Whether the held barrier must be recorded before the copy described so far: it reads something a held copy + * wrote, writes something a held copy read or wrote, or a list is full. Does nothing when no barrier is held. + */ + public boolean mustFlush() { + if (!this.pending) { + return false; + } + if (this.writeCount >= MAX || this.readCount >= MAX || this.imageCount >= MAX) { + return true; + } + if (this.hasSrc && overlaps(this.writes, this.writeCount, this.srcBuffer, this.srcStart, this.srcEnd)) { + return true; + } + if (this.hasDst && (overlaps(this.writes, this.writeCount, this.dstBuffer, this.dstStart, this.dstEnd) + || overlaps(this.reads, this.readCount, this.dstBuffer, this.dstStart, this.dstEnd))) { + return true; + } + return this.hasImage && overlapsImage(); + } + + /** The copy was recorded and its barrier held back: remember what it touched. */ + public void commit() { + this.defer = false; + if (this.hasSrc) { + this.readCount = add(this.reads, this.readCount, this.srcBuffer, this.srcStart, this.srcEnd); + } + if (this.hasDst) { + this.writeCount = add(this.writes, this.writeCount, this.dstBuffer, this.dstStart, this.dstEnd); + } + if (this.hasImage) { + int i = this.imageCount * 6; + this.images[i] = this.image; + this.images[i + 1] = this.subresource; + this.images[i + 2] = this.rectLow >> 32; + this.images[i + 3] = (int) this.rectLow; + this.images[i + 4] = this.rectHigh >> 32; + this.images[i + 5] = (int) this.rectHigh; + this.imageCount++; + } + if (this.pending) { + this.merged++; + mergedTotal++; + } + this.pending = true; + } + + /** The held barrier was recorded (or there is nothing to record it into): start over. */ + public void clear() { + this.pending = false; + this.writeCount = 0; + this.readCount = 0; + this.imageCount = 0; + } + + /** At submit: publishes the frame's count. */ + public void endFrame() { + mergedLastFrame = this.merged; + this.merged = 0; + } + + private static boolean overlaps(long[] list, int count, long buffer, long start, long end) { + for (int i = 0, n = count * 3; i < n; i += 3) { + if (list[i] == buffer && start < list[i + 2] && list[i + 1] < end) { + return true; + } + } + return false; + } + + /** Appends a range, or extends the last one when it is the same buffer and the new range starts at its end. */ + private static int add(long[] list, int count, long buffer, long start, long end) { + if (count > 0) { + int last = (count - 1) * 3; + if (list[last] == buffer && list[last + 2] == start) { + list[last + 2] = end; + return count; + } + } + int i = count * 3; + list[i] = buffer; + list[i + 1] = start; + list[i + 2] = end; + return count + 1; + } + + private boolean overlapsImage() { + long x0 = this.rectLow >> 32; + long y0 = (int) this.rectLow; + long x1 = this.rectHigh >> 32; + long y1 = (int) this.rectHigh; + for (int i = 0, n = this.imageCount * 6; i < n; i += 6) { + if (this.images[i] == this.image && this.images[i + 1] == this.subresource + && x0 < this.images[i + 4] && this.images[i + 2] < x1 + && y0 < this.images[i + 5] && this.images[i + 3] < y1) { + return true; + } + } + return false; + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameAnimationStateMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameAnimationStateMixin.java new file mode 100644 index 0000000..b7e76a3 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameAnimationStateMixin.java @@ -0,0 +1,70 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; + +import it.cosimo.vesuvio.frame.AnimatedTextures; +import it.cosimo.vesuvio.frame.AnimationStateExt; +import it.cosimo.vesuvio.frame.SpriteContentsExt; +import net.minecraft.client.renderer.texture.SpriteContents; + +/** + * A hidden sprite answers "no" to needsToDraw (its frame counter still advances in tick, untouched). When it is seen + * again, or the setting goes off, it is marked dirty so its current frame is drawn on that same tick. + */ +@Mixin(SpriteContents.AnimationState.class) +public abstract class FrameAnimationStateMixin implements AnimationStateExt { + @Shadow + private boolean isDirty; + + @Unique + private @Nullable SpriteContents vesuvio$ownerSprite; + @Unique + private boolean vesuvio$skipped; + + @Override + public @Nullable SpriteContents vesuvio$owner() { + return this.vesuvio$ownerSprite; + } + + @Override + public void vesuvio$setOwner(SpriteContents owner) { + this.vesuvio$ownerSprite = owner; + } + + @Override + public boolean vesuvio$stale() { + return this.vesuvio$skipped; + } + + @Override + public void vesuvio$setStale(boolean stale) { + this.vesuvio$skipped = stale; + } + + @Inject(method = "tick", at = @At("TAIL")) + private void vesuvio$resumeIfSeen(CallbackInfo ci) { + if (AnimatedTextures.afterStateTick(this)) { + this.isDirty = true; + } + } + + @ModifyReturnValue(method = "needsToDraw", at = @At("RETURN")) + private boolean vesuvio$skipHidden(boolean original) { + return AnimatedTextures.needsToDraw(this, original); + } + + @Inject(method = "close", at = @At("HEAD")) + private void vesuvio$unlink(CallbackInfo ci) { + if (this.vesuvio$ownerSprite != null) { + ((SpriteContentsExt) this.vesuvio$ownerSprite).vesuvio$setLive(false); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameAtlasManagerMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameAtlasManagerMixin.java new file mode 100644 index 0000000..c4a6bce --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameAtlasManagerMixin.java @@ -0,0 +1,18 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.frame.AnimatedTextures; +import net.minecraft.client.resources.model.sprite.AtlasManager; + +/** One texture tick starts: advance the "seen" clock. */ +@Mixin(AtlasManager.class) +public abstract class FrameAtlasManagerMixin { + @Inject(method = "tick", at = @At("HEAD")) + private void vesuvio$beginTextureTick(CallbackInfo ci) { + AnimatedTextures.beginTick(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameAtlasSpriteMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameAtlasSpriteMixin.java new file mode 100644 index 0000000..a0c30bb --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameAtlasSpriteMixin.java @@ -0,0 +1,34 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.mojang.blaze3d.systems.RenderSystem; + +import it.cosimo.vesuvio.frame.AnimatedTextures; +import net.minecraft.client.renderer.texture.SpriteContents; +import net.minecraft.client.renderer.texture.TextureAtlasSprite; + +/** + * Anything that draws a sprite from its UVs (particles, entity fire, screen overlays, block entity textures through + * SpriteCoordinateExpander, mods) asks for them here on the render thread: that counts as seen. One field compare + * (and at most one write per texture tick) per call. + */ +@Mixin(TextureAtlasSprite.class) +public abstract class FrameAtlasSpriteMixin { + @Shadow + @Final + private SpriteContents contents; + + @ModifyReturnValue(method = { "getU0", "getU1", "getV0", "getV1", "getU", "getV" }, at = @At("RETURN")) + private float vesuvio$markSeen(float uv) { + // Render thread only: compile workers read fluid UVs here too, and terrain is tracked per section instead. + if (AnimatedTextures.active && RenderSystem.isOnRenderThread()) { + AnimatedTextures.mark(this.contents); + } + return uv; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameBlockModelFeatureRendererMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameBlockModelFeatureRendererMixin.java new file mode 100644 index 0000000..5ce84fa --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameBlockModelFeatureRendererMixin.java @@ -0,0 +1,21 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +import it.cosimo.vesuvio.frame.AnimatedTextures; +import net.minecraft.client.renderer.feature.BlockModelFeatureRenderer; +import net.minecraft.client.resources.model.geometry.BakedQuad; + +/** Block models drawn by entities (carried blocks, minecart contents, TNT, block displays): mark each quad. */ +@Mixin(BlockModelFeatureRenderer.class) +public abstract class FrameBlockModelFeatureRendererMixin { + @ModifyArg(method = "putQuad", at = @At(value = "INVOKE", + target = "Lcom/mojang/blaze3d/vertex/VertexConsumer;putBakedQuad(Lcom/mojang/blaze3d/vertex/PoseStack$Pose;Lnet/minecraft/client/resources/model/geometry/BakedQuad;Lcom/mojang/blaze3d/vertex/QuadInstance;)V"), + index = 1) + private static BakedQuad vesuvio$markQuad(BakedQuad quad) { + AnimatedTextures.markQuad(quad); + return quad; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameCompiledSectionMeshMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameCompiledSectionMeshMixin.java new file mode 100644 index 0000000..3a57fcf --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameCompiledSectionMeshMixin.java @@ -0,0 +1,34 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.frame.CompiledMeshExt; +import it.cosimo.vesuvio.frame.SpriteCollectorHolder; +import it.cosimo.vesuvio.frame.SpriteSet; +import net.minecraft.client.renderer.chunk.CompiledSectionMesh; +import net.minecraft.client.renderer.chunk.SectionCompiler; +import net.minecraft.client.renderer.chunk.TranslucencyPointOfView; +import net.minecraft.client.renderer.texture.SpriteContents; + +/** The animated sprites of this mesh (null = unknown: compiled while the setting was off). */ +@Mixin(CompiledSectionMesh.class) +public abstract class FrameCompiledSectionMeshMixin implements CompiledMeshExt { + @Unique + private SpriteContents[] vesuvio$sprites; + + @Override + public SpriteContents[] vesuvio$animatedSprites() { + return this.vesuvio$sprites; + } + + @Inject(method = "", at = @At("TAIL")) + private void vesuvio$takeSprites(TranslucencyPointOfView pointOfView, SectionCompiler.Results results, + long compileTaskStartTimeNs, CallbackInfo ci) { + SpriteSet sprites = ((SpriteCollectorHolder) (Object) results).vesuvio$sprites(); + this.vesuvio$sprites = sprites == null ? null : sprites.toArray(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameFramebufferCacheMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameFramebufferCacheMixin.java new file mode 100644 index 0000000..612f8b9 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameFramebufferCacheMixin.java @@ -0,0 +1,104 @@ +package it.cosimo.vesuvio.frame.mixin; + +import java.nio.LongBuffer; + +import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkAllocationCallbacks; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkFramebufferCreateInfo; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.renderpearl.backend.vulkan.Destroyable; +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; +import com.mojang.renderpearl.backend.vulkan.VulkanDevice; + +import it.cosimo.vesuvio.frame.FramebufferCache; +import it.cosimo.vesuvio.frame.FramebufferCacheHolder; + +/** + * Setting framebufferCache ({@link FramebufferCache}): createRenderPass begins on a kept framebuffer when one with the + * same render pass, views and size exists, and keeps the one it creates instead of queueing it for destruction. With + * the setting off the calls run as vanilla (and whatever is still kept is queued for destruction). + */ +@Mixin(VulkanCommandEncoder.class) +public abstract class FrameFramebufferCacheMixin implements FramebufferCacheHolder { + @Shadow + @Final + private VulkanDevice device; + + @Unique + private FramebufferCache.Table vesuvio$framebuffers; + /** The framebuffer of the pass being begun belongs to the table: vanilla's destroy right after must not run. */ + @Unique + private boolean vesuvio$framebufferKept; + + @Override + public FramebufferCache.Table vesuvio$framebuffers() { + return this.vesuvio$framebuffers; + } + + @WrapOperation(method = "createRenderPass", at = @At(value = "INVOKE", + target = "Lorg/lwjgl/vulkan/VK12;vkCreateFramebuffer(Lorg/lwjgl/vulkan/VkDevice;Lorg/lwjgl/vulkan/VkFramebufferCreateInfo;Lorg/lwjgl/vulkan/VkAllocationCallbacks;Ljava/nio/LongBuffer;)I")) + private int vesuvio$keptFramebuffer(VkDevice vkDevice, VkFramebufferCreateInfo info, VkAllocationCallbacks allocator, + LongBuffer framebuffer, Operation original) { + this.vesuvio$framebufferKept = false; + FramebufferCache.Table table = this.vesuvio$framebuffers; + if (!FramebufferCache.SETTING.on() || !FramebufferCache.Table.cacheable(info)) { + if (table != null && !table.isEmpty() && !FramebufferCache.SETTING.on()) { + table.clear((VulkanCommandEncoder) (Object) this); + } + return original.call(vkDevice, info, allocator, framebuffer); + } + if (table == null) { + table = new FramebufferCache.Table(this.device.vkDevice()); + this.vesuvio$framebuffers = table; + } + long kept = table.find(info); + if (kept != VK10.VK_NULL_HANDLE) { + framebuffer.put(framebuffer.position(), kept); + this.vesuvio$framebufferKept = true; + return VK10.VK_SUCCESS; + } + int result = original.call(vkDevice, info, allocator, framebuffer); + if (result == VK10.VK_SUCCESS) { + table.put(info, framebuffer.get(framebuffer.position()), (VulkanCommandEncoder) (Object) this); + this.vesuvio$framebufferKept = true; + } + return result; + } + + // The only queueForDestroy in createRenderPass: vanilla's destroy of the pass's framebuffer. + @WrapWithCondition(method = "createRenderPass", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanCommandEncoder;queueForDestroy(Lcom/mojang/renderpearl/backend/vulkan/Destroyable;)V")) + private boolean vesuvio$keepFramebuffer(VulkanCommandEncoder self, Destroyable destroyable) { + if (this.vesuvio$framebufferKept) { + this.vesuvio$framebufferKept = false; + return false; + } + return true; + } + + @Inject(method = "submit", at = @At("HEAD")) + private void vesuvio$framebufferCounters(CallbackInfo ci) { + if (this.vesuvio$framebuffers != null) { + this.vesuvio$framebuffers.endFrame(); + } + } + + // Device closing: queued before the encoder waits for the GPU and drains its destroy queue. + @Inject(method = "destroy", at = @At("HEAD")) + private void vesuvio$destroyFramebuffers(CallbackInfo ci) { + if (this.vesuvio$framebuffers != null) { + this.vesuvio$framebuffers.clear((VulkanCommandEncoder) (Object) this); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameFramerateLimitTrackerMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameFramerateLimitTrackerMixin.java new file mode 100644 index 0000000..6e948ce --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameFramerateLimitTrackerMixin.java @@ -0,0 +1,25 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.mojang.blaze3d.platform.FramerateLimitTracker; + +import it.cosimo.vesuvio.frame.IdleFps; +import net.minecraft.client.Minecraft; + +/** Unfocused or hidden window, single player paused behind a menu: cap the frame rate. */ +@Mixin(FramerateLimitTracker.class) +public abstract class FrameFramerateLimitTrackerMixin { + @Shadow + @Final + private Minecraft minecraft; + + @ModifyReturnValue(method = "getFramerateLimit", at = @At("RETURN")) + private int vesuvio$idleLimit(int vanilla) { + return IdleFps.limit(vanilla, this.minecraft); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameGpuTimersEncoderMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameGpuTimersEncoderMixin.java new file mode 100644 index 0000000..47dbf18 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameGpuTimersEncoderMixin.java @@ -0,0 +1,125 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.jspecify.annotations.Nullable; +import org.lwjgl.vulkan.VkCommandBuffer; +import org.lwjgl.vulkan.VkRenderPassBeginInfo; +import org.lwjgl.vulkan.VkSubpassBeginInfo; +import org.lwjgl.vulkan.VkSubpassEndInfo; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.sugar.Local; +import com.mojang.renderpearl.api.commands.RenderPassDescriptor; +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; +import com.mojang.renderpearl.backend.vulkan.VulkanDevice; + +import it.cosimo.vesuvio.frame.GpuPassTimers; + +/** + * Setting gpuPassTimers ({@link GpuPassTimers}): timestamps right before each render pass begins and right after it + * ends (outside the pass), at the start and end of the frame's command buffers, and the per-frame pool handling in + * submit. Only adds timestamp commands, and only while the setting is on; every vanilla call runs unchanged. + */ +@Mixin(VulkanCommandEncoder.class) +public abstract class FrameGpuTimersEncoderMixin implements GpuPassTimers.Host { + @Shadow + @Final + private VulkanDevice device; + + @Shadow + private long currentSubmitIndex; + + /** Null while not measuring. */ + @Unique + private GpuPassTimers.@Nullable Recorder vesuvio$gpuTimers; + + @Override + public GpuPassTimers.@Nullable Recorder vesuvio$gpuTimers() { + return this.vesuvio$gpuTimers; + } + + /** + * A new frame starts recording: submit has just waited for the frame before last, which used the same slot + * (currentSubmitIndex is already the new frame's), and the staging command buffer of the new frame is begun next. + */ + @Inject(method = "submit", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanTransientMemory;beginSubmit()V")) + private void vesuvio$gpuTimersFrameStart(CallbackInfo ci) { + this.vesuvio$gpuTimers = GpuPassTimers.frameStart(this.vesuvio$gpuTimers, this.device, + (VulkanCommandEncoder) (Object) this, (int) (this.currentSubmitIndex % 2L)); + } + + // Device closing: queued before the encoder waits for the GPU and drains its destroy queue. + @Inject(method = "destroy", at = @At("HEAD")) + private void vesuvio$gpuTimersClose(CallbackInfo ci) { + GpuPassTimers.Recorder r = this.vesuvio$gpuTimers; + if (r != null) { + this.vesuvio$gpuTimers = null; + r.close((VulkanCommandEncoder) (Object) this); + } + } + + @WrapOperation(method = "createRenderPass", at = @At(value = "INVOKE", + target = "Lorg/lwjgl/vulkan/VK12;vkCmdBeginRenderPass2(Lorg/lwjgl/vulkan/VkCommandBuffer;Lorg/lwjgl/vulkan/VkRenderPassBeginInfo;Lorg/lwjgl/vulkan/VkSubpassBeginInfo;)V")) + private void vesuvio$gpuTimePassBegin(VkCommandBuffer commandBuffer, VkRenderPassBeginInfo beginInfo, + VkSubpassBeginInfo subpassBeginInfo, Operation original, + @Local(argsOnly = true) RenderPassDescriptor descriptor) { + GpuPassTimers.Recorder r = this.vesuvio$gpuTimers; + if (r != null) { + r.passBegin(commandBuffer, descriptor.label()); + } + original.call(commandBuffer, beginInfo, subpassBeginInfo); + } + + @WrapOperation(method = "submitRenderPass", at = @At(value = "INVOKE", + target = "Lorg/lwjgl/vulkan/VK12;vkCmdEndRenderPass2(Lorg/lwjgl/vulkan/VkCommandBuffer;Lorg/lwjgl/vulkan/VkSubpassEndInfo;)V")) + private void vesuvio$gpuTimePassEnd(VkCommandBuffer commandBuffer, VkSubpassEndInfo endInfo, + Operation original) { + original.call(commandBuffer, endInfo); + GpuPassTimers.Recorder r = this.vesuvio$gpuTimers; + if (r != null) { + r.passEnd(commandBuffer); + } + } + + /** The frame's main command buffer was just begun (never inside a render pass: commandBuffer() refuses that). */ + @ModifyExpressionValue(method = "commandBuffer", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanCommandEncoder;allocateAndBeginTransientCommandBuffer()Lorg/lwjgl/vulkan/VkCommandBuffer;")) + private VkCommandBuffer vesuvio$gpuTimeMainBuffer(VkCommandBuffer commandBuffer) { + GpuPassTimers.Recorder r = this.vesuvio$gpuTimers; + if (r != null) { + r.bufferBegin(commandBuffer, false); + } + return commandBuffer; + } + + /** The object-init command buffer (first contents of new textures and buffers) was just begun. */ + @ModifyExpressionValue(method = "objectInitCommandBuffer", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanCommandEncoder;allocateAndBeginTransientCommandBuffer()Lorg/lwjgl/vulkan/VkCommandBuffer;")) + private VkCommandBuffer vesuvio$gpuTimeInitBuffer(VkCommandBuffer commandBuffer) { + GpuPassTimers.Recorder r = this.vesuvio$gpuTimers; + if (r != null) { + r.bufferBegin(commandBuffer, true); + } + return commandBuffer; + } + + /** Both vkEndCommandBuffer calls of endCommandBuffer (init, then main); never inside a pass (it throws first). */ + @WrapOperation(method = "endCommandBuffer", at = @At(value = "INVOKE", + target = "Lorg/lwjgl/vulkan/VK12;vkEndCommandBuffer(Lorg/lwjgl/vulkan/VkCommandBuffer;)I")) + private int vesuvio$gpuTimeBufferEnd(VkCommandBuffer commandBuffer, Operation original) { + GpuPassTimers.Recorder r = this.vesuvio$gpuTimers; + if (r != null) { + r.bufferEnd(commandBuffer); + } + return original.call(commandBuffer); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameGpuTimersStagingMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameGpuTimersStagingMixin.java new file mode 100644 index 0000000..d269ebd --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameGpuTimersStagingMixin.java @@ -0,0 +1,51 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.lwjgl.vulkan.VkCommandBuffer; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; +import com.mojang.renderpearl.backend.vulkan.VulkanTransientMemory; + +import it.cosimo.vesuvio.frame.GpuPassTimers; + +/** + * Setting gpuPassTimers ({@link GpuPassTimers}): the transient staging command buffer, which runs first in each + * frame's submit, gets a timestamp at its start and its end (counted as "uploads"). Only adds timestamps while the + * encoder is measuring. + */ +@Mixin(VulkanTransientMemory.class) +public abstract class FrameGpuTimersStagingMixin { + @Shadow + @Final + private VulkanCommandEncoder encoder; + + @ModifyExpressionValue(method = "beginSubmit", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanCommandEncoder;allocateAndBeginTransientCommandBuffer()Lorg/lwjgl/vulkan/VkCommandBuffer;")) + private VkCommandBuffer vesuvio$gpuTimeStagingBegin(VkCommandBuffer commandBuffer) { + if (this.encoder instanceof GpuPassTimers.Host host) { + GpuPassTimers.Recorder r = host.vesuvio$gpuTimers(); + if (r != null) { + r.bufferBegin(commandBuffer, true); + } + } + return commandBuffer; + } + + @WrapOperation(method = "endSubmit", at = @At(value = "INVOKE", + target = "Lorg/lwjgl/vulkan/VK12;vkEndCommandBuffer(Lorg/lwjgl/vulkan/VkCommandBuffer;)I")) + private int vesuvio$gpuTimeStagingEnd(VkCommandBuffer commandBuffer, Operation original) { + if (this.encoder instanceof GpuPassTimers.Host host) { + GpuPassTimers.Recorder r = host.vesuvio$gpuTimers(); + if (r != null) { + r.bufferEnd(commandBuffer); + } + } + return original.call(commandBuffer); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameItemFeatureRendererMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameItemFeatureRendererMixin.java new file mode 100644 index 0000000..545c706 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameItemFeatureRendererMixin.java @@ -0,0 +1,29 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +import it.cosimo.vesuvio.frame.AnimatedTextures; +import net.minecraft.client.renderer.feature.ItemFeatureRenderer; +import net.minecraft.client.resources.model.geometry.BakedQuad; + +/** Items (in the world, in hand, in the GUI item atlas): every quad written marks its sprite as seen. */ +@Mixin(ItemFeatureRenderer.class) +public abstract class FrameItemFeatureRendererMixin { + @ModifyArg(method = { "prepareMainSubmit", "prepareOutlineSubmit" }, at = @At(value = "INVOKE", + target = "Lcom/mojang/blaze3d/vertex/VertexConsumer;putBakedQuad(Lcom/mojang/blaze3d/vertex/PoseStack$Pose;Lnet/minecraft/client/resources/model/geometry/BakedQuad;Lcom/mojang/blaze3d/vertex/QuadInstance;)V"), + index = 1) + private BakedQuad vesuvio$markQuad(BakedQuad quad) { + AnimatedTextures.markQuad(quad); + return quad; + } + + @ModifyArg(method = "prepareMainSubmit", at = @At(value = "INVOKE", + target = "Lcom/mojang/blaze3d/vertex/VertexConsumer;putBakedQuadWithGlint(Lcom/mojang/blaze3d/vertex/PoseStack$Pose;Lnet/minecraft/client/resources/model/geometry/BakedQuad;Lcom/mojang/blaze3d/vertex/QuadInstance;Lcom/mojang/blaze3d/vertex/PoseStack$Pose;)V"), + index = 1) + private BakedQuad vesuvio$markGlintQuad(BakedQuad quad) { + AnimatedTextures.markQuad(quad); + return quad; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameLevelRendererMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameLevelRendererMixin.java new file mode 100644 index 0000000..692176d --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameLevelRendererMixin.java @@ -0,0 +1,24 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; + +import it.cosimo.vesuvio.frame.AnimatedTextures; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.client.renderer.chunk.SectionMesh; + +/** + * Each section prepared for drawing marks its animated sprites as seen. Both prepareChunkRenders variants go + * through extractSectionDrawGroups, so Etna's shadow casters count too. Nothing about the drawing changes. + */ +@Mixin(LevelRenderer.class) +public abstract class FrameLevelRendererMixin { + @ModifyExpressionValue(method = "extractSectionDrawGroups", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/chunk/SectionRenderDispatcher$RenderSection;getSectionMesh()Lnet/minecraft/client/renderer/chunk/SectionMesh;")) + private SectionMesh vesuvio$markSectionSprites(SectionMesh mesh) { + AnimatedTextures.markMesh(mesh); + return mesh; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameMinecraftMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameMinecraftMixin.java new file mode 100644 index 0000000..45808ed --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameMinecraftMixin.java @@ -0,0 +1,46 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.blaze3d.platform.Window; + +import it.cosimo.vesuvio.frame.IdleFps; +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.model.sprite.AtlasManager; + +/** Minimized or hidden window: skip drawing (ticks, packets and sound still run every loop). */ +@Mixin(Minecraft.class) +public abstract class FrameMinecraftMixin { + @Shadow + public abstract Window getWindow(); + + @Inject(method = "runTick", at = @At("HEAD")) + private void vesuvio$decideIdleFrame(boolean advanceGameTime, CallbackInfo ci) { + IdleFps.beginLoop(this.getWindow()); + } + + @WrapOperation(method = "runTick", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/resources/model/sprite/AtlasManager;tick()V")) + private void vesuvio$skipHiddenAnimationTick(AtlasManager atlasManager, Operation original) { + if (!IdleFps.skipping()) { + original.call(atlasManager); + } + } + + @WrapOperation(method = "runTick", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/Minecraft;renderFrame(Z)V")) + private void vesuvio$skipHiddenFrame(Minecraft minecraft, boolean advanceGameTime, Operation original) { + if (IdleFps.skipping()) { + IdleFps.skipFrame(); + return; + } + original.call(minecraft, advanceGameTime); + IdleFps.frameRendered(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameMovingBlockFeatureRendererMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameMovingBlockFeatureRendererMixin.java new file mode 100644 index 0000000..d648ec2 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameMovingBlockFeatureRendererMixin.java @@ -0,0 +1,21 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyArg; + +import it.cosimo.vesuvio.frame.AnimatedTextures; +import net.minecraft.client.renderer.feature.MovingBlockFeatureRenderer; +import net.minecraft.client.resources.model.geometry.BakedQuad; + +/** Moving blocks (pistons, falling blocks): mark each quad. */ +@Mixin(MovingBlockFeatureRenderer.class) +public abstract class FrameMovingBlockFeatureRendererMixin { + @ModifyArg(method = "putBakedQuad", at = @At(value = "INVOKE", + target = "Lcom/mojang/blaze3d/vertex/VertexConsumer;putBakedQuad(Lcom/mojang/blaze3d/vertex/PoseStack$Pose;Lnet/minecraft/client/resources/model/geometry/BakedQuad;Lcom/mojang/blaze3d/vertex/QuadInstance;)V"), + index = 1) + private BakedQuad vesuvio$markQuad(BakedQuad quad) { + AnimatedTextures.markQuad(quad); + return quad; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameMultiPartModelMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameMultiPartModelMixin.java new file mode 100644 index 0000000..98feb91 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameMultiPartModelMixin.java @@ -0,0 +1,50 @@ +package it.cosimo.vesuvio.frame.mixin; + +import java.util.List; + +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; + +import it.cosimo.vesuvio.frame.ModelSprites; +import it.cosimo.vesuvio.frame.SpriteSet; +import net.minecraft.client.renderer.block.dispatch.BlockStateModel; +import net.minecraft.client.renderer.block.dispatch.multipart.MultiPartModel; +import net.minecraft.client.renderer.texture.SpriteContents; + +/** The animated sprites of a multipart model (one per block state): those of every part this state selects. */ +@Mixin(MultiPartModel.class) +public abstract class FrameMultiPartModelMixin implements ModelSprites.Holder { + @Shadow + private @Nullable List models; + + @Unique + private volatile SpriteContents @Nullable [] vesuvio$animated; + @Unique + private volatile boolean vesuvio$unknown; + + @Override + public SpriteContents @Nullable [] vesuvio$animatedSprites() { + SpriteContents[] sprites = this.vesuvio$animated; + if (sprites == null && !this.vesuvio$unknown) { + // The selected parts are filled in on the first collectParts (vanilla does the same, lazily). + ModelSprites.partsOf((BlockStateModel) this); + List selected = this.models; + if (selected == null) { + this.vesuvio$unknown = true; + return null; + } + SpriteSet set = new SpriteSet(); + for (BlockStateModel model : selected) { + if (!ModelSprites.addModel(model, set)) { + this.vesuvio$unknown = true; + return null; + } + } + sprites = set.toArray(); + this.vesuvio$animated = sprites; + } + return sprites; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FramePipelineCacheCompileMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FramePipelineCacheCompileMixin.java new file mode 100644 index 0000000..dea6517 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FramePipelineCacheCompileMixin.java @@ -0,0 +1,30 @@ +package it.cosimo.vesuvio.frame.mixin; + +import java.nio.LongBuffer; + +import org.lwjgl.vulkan.VkAllocationCallbacks; +import org.lwjgl.vulkan.VkDevice; +import org.lwjgl.vulkan.VkGraphicsPipelineCreateInfo; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.renderpearl.backend.vulkan.VulkanRenderPipeline; + +import it.cosimo.vesuvio.frame.PipelineDiskCache; + +/** Setting pipelineCache: every render pipeline is compiled through the disk-backed cache, and timed. */ +@Mixin(VulkanRenderPipeline.class) +public abstract class FramePipelineCacheCompileMixin { + @WrapOperation(method = "compile", at = @At(value = "INVOKE", + target = "Lorg/lwjgl/vulkan/VK12;vkCreateGraphicsPipelines(Lorg/lwjgl/vulkan/VkDevice;JLorg/lwjgl/vulkan/VkGraphicsPipelineCreateInfo$Buffer;Lorg/lwjgl/vulkan/VkAllocationCallbacks;Ljava/nio/LongBuffer;)I")) + private static int vesuvio$compileWithCache(VkDevice device, long pipelineCache, VkGraphicsPipelineCreateInfo.Buffer info, + VkAllocationCallbacks allocator, LongBuffer out, Operation original) { + long cache = pipelineCache == 0L ? PipelineDiskCache.handle(device) : pipelineCache; + long start = System.nanoTime(); + int result = original.call(device, cache, info, allocator, out); + PipelineDiskCache.compiled(System.nanoTime() - start); + return result; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FramePipelineCacheDeviceMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FramePipelineCacheDeviceMixin.java new file mode 100644 index 0000000..11bc3f3 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FramePipelineCacheDeviceMixin.java @@ -0,0 +1,35 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.lwjgl.vulkan.VkDevice; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.sugar.Local; +import com.mojang.renderpearl.backend.vulkan.VulkanDevice; +import com.mojang.renderpearl.backend.vulkan.VulkanPhysicalDevice; + +import it.cosimo.vesuvio.frame.PipelineDiskCache; + +/** Setting pipelineCache: opens the disk-backed pipeline cache with the device and saves it when the device closes. */ +@Mixin(VulkanDevice.class) +public abstract class FramePipelineCacheDeviceMixin { + @Shadow + @Final + private VkDevice vkDevice; + + // Just before the constructor frees the physical device's properties (the cache header needs them). + @Inject(method = "", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanPhysicalDevice;close()V")) + private void vesuvio$openPipelineCache(CallbackInfo ci, @Local(argsOnly = true) VulkanPhysicalDevice physicalDevice) { + PipelineDiskCache.open(this.vkDevice, physicalDevice.vkPhysicalDeviceProperties()); + } + + @Inject(method = "close", at = @At("HEAD")) + private void vesuvio$savePipelineCache(CallbackInfo ci) { + PipelineDiskCache.close(this.vkDevice); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSectionCompilerMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSectionCompilerMixin.java new file mode 100644 index 0000000..bee7696 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSectionCompilerMixin.java @@ -0,0 +1,90 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.frame.AnimatedTextures; +import it.cosimo.vesuvio.frame.ModelSprites; +import it.cosimo.vesuvio.frame.SpriteCollectorHolder; +import it.cosimo.vesuvio.frame.SpriteSet; +import net.minecraft.client.renderer.block.dispatch.BlockStateModel; +import net.minecraft.client.renderer.block.FluidModel; +import net.minecraft.client.renderer.block.FluidStateModelSet; +import net.minecraft.client.renderer.block.ModelBlockRenderer; +import net.minecraft.client.renderer.chunk.SectionCompiler; +import net.minecraft.world.level.material.Fluid; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.level.material.Fluids; + +/** + * While a section compiles (worker thread), collect the animated sprites its block models and fluids use. The set + * rides on the compile's Results into the CompiledSectionMesh. Off: nothing is collected and the mesh says "unknown". + */ +@Mixin(SectionCompiler.class) +public abstract class FrameSectionCompilerMixin { + @Shadow + @Final + private FluidStateModelSet fluidModelSet; + + @ModifyExpressionValue(method = "compile", at = @At(value = "NEW", + target = "net/minecraft/client/renderer/block/ModelBlockRenderer")) + private ModelBlockRenderer vesuvio$collectSprites(ModelBlockRenderer renderer, + @Local SectionCompiler.Results results) { + if (AnimatedTextures.SETTING.on()) { + ((SpriteCollectorHolder) (Object) results).vesuvio$setSprites(new SpriteSet()); + } + return renderer; + } + + /** + * Every block with a model: its model's animated sprites. The model lookup is an argument of the tesselate call, + * so it runs whichever renderer then draws the block (Fabric API redirects that call to its Indigo renderer). + */ + @ModifyExpressionValue(method = "compile", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/block/BlockStateModelSet;get(Lnet/minecraft/world/level/block/state/BlockState;)Lnet/minecraft/client/renderer/block/dispatch/BlockStateModel;")) + private BlockStateModel vesuvio$collectModelSprites(BlockStateModel model, @Local SectionCompiler.Results results) { + SpriteSet sprites = ((SpriteCollectorHolder) (Object) results).vesuvio$sprites(); + if (sprites != null) { + ModelSprites.collect(model, sprites); + } + return model; + } + + /** + * Every fluid: its model's sprites. Only water and lava have vanilla fluid models; any other fluid (from a mod) is + * drawn with sprites this cannot know (Fabric API's fluid render handlers), so its section is "unknown". The hook + * is the "has a fluid" test right before the fluid is tesselated; what it collects does not depend on the + * tesselation, so collecting just before it instead of just after gives the same set. + */ + @ModifyExpressionValue(method = "compile", at = @At(value = "INVOKE", + target = "Lnet/minecraft/world/level/material/FluidState;isEmpty()Z")) + private boolean vesuvio$collectFluidSprites(boolean empty, @Local FluidState fluidState, + @Local SectionCompiler.Results results) { + if (!empty) { + SpriteSet sprites = ((SpriteCollectorHolder) (Object) results).vesuvio$sprites(); + if (sprites != null) { + this.vesuvio$addFluid(fluidState, sprites); + } + } + return empty; + } + + private void vesuvio$addFluid(FluidState fluidState, SpriteSet sprites) { + Fluid type = fluidState.getType(); + if (type != Fluids.WATER && type != Fluids.FLOWING_WATER && type != Fluids.LAVA && type != Fluids.FLOWING_LAVA) { + sprites.markUnknown(); + return; + } + FluidModel model = this.fluidModelSet.get(fluidState); + sprites.add(model.stillMaterial().sprite()); + sprites.add(model.flowingMaterial().sprite()); + if (model.overlayMaterial() != null) { + sprites.add(model.overlayMaterial().sprite()); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSectionResultsMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSectionResultsMixin.java new file mode 100644 index 0000000..dd80d9c --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSectionResultsMixin.java @@ -0,0 +1,26 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import it.cosimo.vesuvio.frame.SpriteCollectorHolder; +import it.cosimo.vesuvio.frame.SpriteSet; +import net.minecraft.client.renderer.chunk.SectionCompiler; + +/** Carries the compile's animated sprites to the mesh. */ +@Mixin(SectionCompiler.Results.class) +public abstract class FrameSectionResultsMixin implements SpriteCollectorHolder { + @Unique + private @Nullable SpriteSet vesuvio$spriteSet; + + @Override + public @Nullable SpriteSet vesuvio$sprites() { + return this.vesuvio$spriteSet; + } + + @Override + public void vesuvio$setSprites(@Nullable SpriteSet sprites) { + this.vesuvio$spriteSet = sprites; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSingleVariantMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSingleVariantMixin.java new file mode 100644 index 0000000..9cd68e1 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSingleVariantMixin.java @@ -0,0 +1,36 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; + +import it.cosimo.vesuvio.frame.ModelSprites; +import it.cosimo.vesuvio.frame.SpriteSet; +import net.minecraft.client.renderer.block.dispatch.BlockStateModelPart; +import net.minecraft.client.renderer.block.dispatch.SingleVariant; +import net.minecraft.client.renderer.texture.SpriteContents; + +/** The animated sprites of a single-variant model: those of its one part. */ +@Mixin(SingleVariant.class) +public abstract class FrameSingleVariantMixin implements ModelSprites.Holder { + @Shadow + @Final + private BlockStateModelPart model; + + @Unique + private volatile SpriteContents @Nullable [] vesuvio$animated; + + @Override + public SpriteContents @Nullable [] vesuvio$animatedSprites() { + SpriteContents[] sprites = this.vesuvio$animated; + if (sprites == null) { + SpriteSet set = new SpriteSet(); + ModelSprites.addPart(this.model, set); + sprites = set.toArray(); + this.vesuvio$animated = sprites; + } + return sprites; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSpriteContentsMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSpriteContentsMixin.java new file mode 100644 index 0000000..f60c0ba --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameSpriteContentsMixin.java @@ -0,0 +1,49 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import it.cosimo.vesuvio.frame.AnimationStateExt; +import it.cosimo.vesuvio.frame.SpriteContentsExt; +import net.minecraft.client.renderer.texture.SpriteContents; + +/** Remembers when a sprite was last seen, and links each animation state to its sprite. */ +@Mixin(SpriteContents.class) +public abstract class FrameSpriteContentsMixin implements SpriteContentsExt { + @Unique + private int vesuvio$seenTick = -1_000_000; + @Unique + private boolean vesuvio$isLive; // render thread only (atlas upload, close, frame marking) + + @Override + public int vesuvio$seen() { + return this.vesuvio$seenTick; + } + + @Override + public void vesuvio$setSeen(int tick) { + this.vesuvio$seenTick = tick; + } + + @Override + public boolean vesuvio$live() { + return this.vesuvio$isLive; + } + + @Override + public void vesuvio$setLive(boolean live) { + this.vesuvio$isLive = live; + } + + @Inject(method = "createAnimationState", at = @At("RETURN")) + private void vesuvio$linkState(CallbackInfoReturnable cir) { + Object state = cir.getReturnValue(); + if (state != null) { + ((AnimationStateExt) state).vesuvio$setOwner((SpriteContents) (Object) this); + this.vesuvio$isLive = true; + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameStagedDrawAccessor.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameStagedDrawAccessor.java new file mode 100644 index 0000000..05be435 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameStagedDrawAccessor.java @@ -0,0 +1,37 @@ +package it.cosimo.vesuvio.frame.mixin; + +import java.util.List; + +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.VertexSorting; +import com.mojang.renderpearl.api.pipeline.IndexType; + +import net.minecraft.client.renderer.StagedVertexBuffer; + +/** Setting mappedVertexPools: what a direct upload needs from a draw (it.cosimo.vesuvio.frame.MappedVertexPools). */ +@Mixin(StagedVertexBuffer.Draw.class) +public interface FrameStagedDrawAccessor { + @Accessor("vertexOffset") + int vesuvio$vertexOffset(); + + @Accessor("indexOffset") + int vesuvio$indexOffset(); + + @Accessor("quadSorting") + @Nullable + VertexSorting vesuvio$quadSorting(); + + @Accessor("vertexBufferSlices") + List vesuvio$vertexBufferSlices(); + + @Invoker("indexType") + IndexType vesuvio$indexType(); + + @Invoker("freeVertexData") + void vesuvio$freeVertexData(); +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameStagedVertexBufferInvoker.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameStagedVertexBufferInvoker.java new file mode 100644 index 0000000..acf8ab5 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameStagedVertexBufferInvoker.java @@ -0,0 +1,17 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Invoker; + +import com.mojang.blaze3d.vertex.CompactVectorArray; + +import net.minecraft.client.renderer.StagedVertexBuffer; + +/** Setting mappedVertexPools: vanilla's own quad centroid decoding for sorted index buffers. */ +@Mixin(StagedVertexBuffer.class) +public interface FrameStagedVertexBufferInvoker { + @Invoker("decodeSortingPoints") + static CompactVectorArray vesuvio$decodeSortingPoints(StagedVertexBuffer.Draw draw) { + throw new AssertionError(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameStagedVertexBufferMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameStagedVertexBufferMixin.java new file mode 100644 index 0000000..f06e53a --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameStagedVertexBufferMixin.java @@ -0,0 +1,29 @@ +package it.cosimo.vesuvio.frame.mixin; + +import java.util.List; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.renderpearl.api.buffers.GpuBuffer; +import com.mojang.renderpearl.api.device.GpuDevice; + +import it.cosimo.vesuvio.frame.MappedVertexPools; +import net.minecraft.client.renderer.StagedVertexBuffer; + +/** + * Setting mappedVertexPools ({@link MappedVertexPools}): when the vertex (and index) buffer just acquired can be + * mapped, the upload writes into it directly instead of through the staging buffer and copyToBuffer. + */ +@Mixin(StagedVertexBuffer.class) +public abstract class FrameStagedVertexBufferMixin { + @Inject(method = "uploadDrawsToBuffers", at = @At("HEAD"), cancellable = true) + private void vesuvio$writeDirect(GpuDevice device, List draws, GpuBuffer vertexGpuBuffer, + GpuBuffer indexGpuBuffer, int vertexBufferSize, int indexBufferSize, CallbackInfo ci) { + if (MappedVertexPools.writeDirect(draws, vertexGpuBuffer, indexGpuBuffer, vertexBufferSize, indexBufferSize)) { + ci.cancel(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameTextureAtlasMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameTextureAtlasMixin.java new file mode 100644 index 0000000..b47c37c --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameTextureAtlasMixin.java @@ -0,0 +1,31 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.frame.AnimatedTextures; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.resources.Identifier; + +/** Hidden sprites are skipped only while the block atlas cycles (never during the first upload after a reload). */ +@Mixin(TextureAtlas.class) +public abstract class FrameTextureAtlasMixin { + @Shadow + @Final + private Identifier location; + + @SuppressWarnings("deprecation") + @Inject(method = "cycleAnimationFrames", at = @At("HEAD")) + private void vesuvio$beginCycle(CallbackInfo ci) { + AnimatedTextures.beginAtlas(TextureAtlas.LOCATION_BLOCKS.equals(this.location)); + } + + @Inject(method = "cycleAnimationFrames", at = @At("RETURN")) + private void vesuvio$endCycle(CallbackInfo ci) { + AnimatedTextures.endAtlas(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameTextureViewCloseMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameTextureViewCloseMixin.java new file mode 100644 index 0000000..73a7cbf --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameTextureViewCloseMixin.java @@ -0,0 +1,45 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; +import com.mojang.renderpearl.backend.vulkan.VulkanDevice; +import com.mojang.renderpearl.backend.vulkan.VulkanGpuTextureView; + +import it.cosimo.vesuvio.frame.FramebufferCache; +import it.cosimo.vesuvio.frame.FramebufferCacheHolder; + +/** + * Setting framebufferCache ({@link FramebufferCache}): a view being closed takes every kept framebuffer naming it + * with it, queued on the destroy queue just before the view itself (runs whatever the setting, so nothing kept while + * it was on can outlive its view). + */ +@Mixin(VulkanGpuTextureView.class) +public abstract class FrameTextureViewCloseMixin { + @Shadow + @Final + private VulkanDevice device; + + @Shadow + private boolean closed; + + @Shadow + public abstract long vkImageView(); + + @Inject(method = "close", at = @At("HEAD")) + private void vesuvio$dropKeptFramebuffers(CallbackInfo ci) { + if (this.closed) { + return; + } + VulkanCommandEncoder encoder = this.device.createCommandEncoder(); + FramebufferCache.Table table = ((FramebufferCacheHolder) encoder).vesuvio$framebuffers(); + if (table != null && !table.isEmpty()) { + table.viewClosed(this.vkImageView(), encoder); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameTransferBarrierMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameTransferBarrierMixin.java new file mode 100644 index 0000000..f50d9fa --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameTransferBarrierMixin.java @@ -0,0 +1,169 @@ +package it.cosimo.vesuvio.frame.mixin; + +import java.nio.ByteBuffer; + +import org.jspecify.annotations.Nullable; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.vulkan.VkCommandBuffer; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import com.mojang.renderpearl.api.buffers.GpuBufferSlice; +import com.mojang.renderpearl.api.textures.GpuTexture; +import com.mojang.renderpearl.backend.api.RenderPassBackend; +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; +import com.mojang.renderpearl.backend.vulkan.VulkanGpuBuffer; +import com.mojang.renderpearl.backend.vulkan.VulkanGpuTexture; + +import it.cosimo.vesuvio.frame.TransferBarriers; + +/** + * Setting mergeTransferBarriers ({@link TransferBarriers}): the barrier after writeToBuffer, copyToBuffer, + * writeToTexture and copyBufferToTexture is held back, and the held barrier is recorded, unchanged, before every + * other command the encoder records into the frame's command buffer and before that buffer ends. With the setting + * off nothing is held and every method runs exactly as vanilla. + */ +@Mixin(VulkanCommandEncoder.class) +public abstract class FrameTransferBarrierMixin { + @Shadow + @Nullable + private VkCommandBuffer currentCommandBuffer; + + @Unique + private final TransferBarriers.Tracker vesuvio$barriers = new TransferBarriers.Tracker(); + + /** Records the held barrier, if any: the same call vanilla makes after each copy. */ + @Unique + private void vesuvio$flushBarrier() { + if (!this.vesuvio$barriers.pending) { + return; + } + this.vesuvio$barriers.clear(); + // A barrier is only held after a copy was recorded into the current command buffer, and only + // endCommandBuffer (which flushes first) clears it, so this is never null in practice. + if (this.currentCommandBuffer != null) { + try (MemoryStack stack = MemoryStack.stackPush()) { + VulkanCommandEncoder.memoryBarrier(this.currentCommandBuffer, stack); + } + } + } + + // Everything that is not a tracked copy: the held barrier goes first. endCommandBuffer covers submit, execute + // (the swapchain blit and other mods' command buffers) and the semaphore operations. + @Inject(method = {"clearColorTexture", "clearColorAndDepthTextures", "clearDepthTexture", "copyTextureToBuffer", + "copyTextureToTexture", "writeTimestamp", "endCommandBuffer"}, at = @At("HEAD")) + private void vesuvio$barrierBeforeCommand(CallbackInfo ci) { + vesuvio$flushBarrier(); + } + + @Inject(method = "createRenderPass", at = @At("HEAD")) + private void vesuvio$barrierBeforePass(CallbackInfoReturnable cir) { + vesuvio$flushBarrier(); + } + + @Inject(method = "submit", at = @At("HEAD")) + private void vesuvio$countFrame(CallbackInfo ci) { + this.vesuvio$barriers.endFrame(); + } + + // The tracked copies. Each one says what it reads and writes before its command is recorded; the held barrier + // is recorded first when it overlaps (or the setting is off). + + @Inject(method = "copyToBuffer", at = @At("HEAD")) + private void vesuvio$beforeCopyToBuffer(GpuBufferSlice source, GpuBufferSlice target, CallbackInfo ci) { + TransferBarriers.Tracker t = this.vesuvio$barriers; + if (t.begin()) { + long src = ((VulkanGpuBuffer) source.buffer()).vkBuffer(); + long dst = ((VulkanGpuBuffer) target.buffer()).vkBuffer(); + t.source(src, source.offset(), source.offset() + source.length()); + // Vanilla copies source.length() bytes. + t.destination(dst, target.offset(), target.offset() + source.length()); + if (t.mustFlush()) { + vesuvio$flushBarrier(); + } + } else { + vesuvio$flushBarrier(); + } + } + + @Inject(method = "copyBufferToTexture", at = @At("HEAD")) + private void vesuvio$beforeCopyBufferToTexture(GpuBufferSlice source, int sourceX, int sourceY, int sourceWidth, + int sourceHeight, GpuTexture destination, int destinationX, int destinationY, int copyWidth, int copyHeight, + int mipLevel, int arrayLayer, CallbackInfo ci) { + TransferBarriers.Tracker t = this.vesuvio$barriers; + if (t.begin()) { + // The rows read can reach past the slice (row length and skipped texels), so the whole rest of the + // buffer counts as read. + t.source(((VulkanGpuBuffer) source.buffer()).vkBuffer(), source.offset(), Long.MAX_VALUE); + t.destinationImage(((VulkanGpuTexture) destination).vkImage(), mipLevel, arrayLayer, destinationX, + destinationY, copyWidth, copyHeight); + if (t.mustFlush()) { + vesuvio$flushBarrier(); + } + } else { + vesuvio$flushBarrier(); + } + } + + @Inject(method = "writeToBuffer", at = @At("HEAD")) + private void vesuvio$beforeWriteToBuffer(GpuBufferSlice destination, ByteBuffer data, CallbackInfo ci) { + TransferBarriers.Tracker t = this.vesuvio$barriers; + if (t.begin()) { + long dst = ((VulkanGpuBuffer) destination.buffer()).vkBuffer(); + t.destination(dst, destination.offset(), destination.offset() + data.remaining()); + // The source (fresh transient staging memory) is known after uploadStaging, below. + } else { + vesuvio$flushBarrier(); + } + } + + @Inject(method = "writeToTexture", at = @At("HEAD")) + private void vesuvio$beforeWriteToTexture(GpuTexture destination, ByteBuffer source, int mipLevel, int depthOrLayer, + int destX, int destY, int width, int height, CallbackInfo ci) { + TransferBarriers.Tracker t = this.vesuvio$barriers; + if (t.begin()) { + t.destinationImage(((VulkanGpuTexture) destination).vkImage(), mipLevel, depthOrLayer, destX, destY, width, + height); + } else { + vesuvio$flushBarrier(); + } + } + + // writeToBuffer / writeToTexture: uploadStaging only copies the bytes into transient memory on the CPU (no + // command), so the check can run after it and before vkCmdCopyBuffer / vkCmdCopyBufferToImage. + @ModifyExpressionValue(method = {"writeToBuffer", "writeToTexture"}, at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanTransientMemory;uploadStaging(Ljava/nio/ByteBuffer;JI)Lcom/mojang/renderpearl/api/buffers/GpuBufferSlice;")) + private GpuBufferSlice vesuvio$checkStagingSource(GpuBufferSlice staging) { + TransferBarriers.Tracker t = this.vesuvio$barriers; + if (t.defer) { + long src = ((VulkanGpuBuffer) staging.buffer()).vkBuffer(); + t.source(src, staging.offset(), staging.offset() + staging.length()); + if (t.mustFlush()) { + vesuvio$flushBarrier(); + } + } + return staging; + } + + // The barrier each tracked copy records after its command: held back when the copy was set up to defer it. + // (Per copy and upload, so these two hooks are a WrapWithCondition and a ModifyExpressionValue: no Operation + // argument array per call.) + @WrapWithCondition(method = {"writeToBuffer", "copyToBuffer", "writeToTexture", "copyBufferToTexture"}, + at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/backend/vulkan/VulkanCommandEncoder;memoryBarrier(Lorg/lwjgl/system/MemoryStack;)V")) + private boolean vesuvio$holdBarrier(VulkanCommandEncoder encoder, MemoryStack stack) { + TransferBarriers.Tracker t = this.vesuvio$barriers; + if (t.defer) { + t.commit(); + return false; + } + return true; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameUnifiedMemoryMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameUnifiedMemoryMixin.java new file mode 100644 index 0000000..990637b --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameUnifiedMemoryMixin.java @@ -0,0 +1,26 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.lwjgl.vulkan.VkDevice; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.renderpearl.backend.vulkan.VulkanDevice; + +import it.cosimo.vesuvio.frame.MappedVertexPools; + +/** Setting mappedVertexPools: reads the GPU's memory heaps once the device exists (only reads). */ +@Mixin(VulkanDevice.class) +public abstract class FrameUnifiedMemoryMixin { + @Shadow + @Final + private VkDevice vkDevice; + + @Inject(method = "", at = @At("TAIL")) + private void vesuvio$readMemoryHeaps(CallbackInfo ci) { + MappedVertexPools.deviceCreated(this.vkDevice); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameVertexCountersMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameVertexCountersMixin.java new file mode 100644 index 0000000..161d578 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameVertexCountersMixin.java @@ -0,0 +1,19 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; + +import it.cosimo.vesuvio.frame.MappedVertexPools; + +/** Setting mappedVertexPools: publishes the frame's counter at submit (only counts). */ +@Mixin(VulkanCommandEncoder.class) +public abstract class FrameVertexCountersMixin { + @Inject(method = "submit", at = @At("HEAD")) + private void vesuvio$vertexCounters(CallbackInfo ci) { + MappedVertexPools.endFrame(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameVertexPoolMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameVertexPoolMixin.java new file mode 100644 index 0000000..fe8281d --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameVertexPoolMixin.java @@ -0,0 +1,46 @@ +package it.cosimo.vesuvio.frame.mixin; + +import java.util.function.Supplier; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.renderpearl.api.buffers.GpuBuffer; +import com.mojang.renderpearl.api.device.GpuDevice; + +import it.cosimo.vesuvio.frame.MappedVertexPools; + +/** + * Setting mappedVertexPools ({@link MappedVertexPools}): StagedVertexBuffer's vertex and index pools make mappable + * buffers on unified memory, and an idle buffer made the other way is replaced instead of handed out. The staging + * pool is left alone. Recycling (fences) is vanilla's. + */ +@Mixin(targets = "net.minecraft.client.renderer.StagedVertexBuffer$GpuBufferPool") +public abstract class FrameVertexPoolMixin { + @Shadow + @Final + private int usage; + + @WrapOperation(method = "acquire", at = @At(value = "INVOKE", + target = "Lcom/mojang/renderpearl/api/device/GpuDevice;createBuffer(Ljava/util/function/Supplier;IJ)Lcom/mojang/renderpearl/api/buffers/GpuBuffer;")) + private GpuBuffer vesuvio$mappableBuffer(GpuDevice device, Supplier label, int usage, long size, + Operation original) { + return original.call(device, label, MappedVertexPools.createUsage(usage), size); + } + + // Both returns of takeBestAvailable (exact size, best fit): only acquire calls it. + @ModifyReturnValue(method = "takeBestAvailable", at = @At("RETURN"), require = 2) + private GpuBuffer vesuvio$reuseMatching(GpuBuffer buffer) { + if (buffer != null && !MappedVertexPools.reusable(this.usage, buffer)) { + // Idle (it came back through the pool's fence): closing it only queues its destruction. + buffer.close(); + return null; + } + return buffer; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameVulkanInstanceMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameVulkanInstanceMixin.java new file mode 100644 index 0000000..444101c --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameVulkanInstanceMixin.java @@ -0,0 +1,24 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.lwjgl.PointerBuffer; +import org.lwjgl.vulkan.VkAllocationCallbacks; +import org.lwjgl.vulkan.VkInstanceCreateInfo; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.renderpearl.backend.vulkan.VulkanInstance; + +import it.cosimo.vesuvio.frame.MoltenVkSettings; + +/** macOS / MoltenVK: pass MoltenVK settings through VK_EXT_layer_settings when the instance is created. */ +@Mixin(VulkanInstance.class) +public abstract class FrameVulkanInstanceMixin { + @WrapOperation(method = "", at = @At(value = "INVOKE", + target = "Lorg/lwjgl/vulkan/VK12;vkCreateInstance(Lorg/lwjgl/vulkan/VkInstanceCreateInfo;Lorg/lwjgl/vulkan/VkAllocationCallbacks;Lorg/lwjgl/PointerBuffer;)I")) + private int vesuvio$createWithMoltenVkSettings(VkInstanceCreateInfo info, VkAllocationCallbacks allocator, + PointerBuffer instance, Operation original) { + return MoltenVkSettings.createInstance(info, i -> original.call(i, allocator, instance)); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameWeightedVariantsMixin.java b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameWeightedVariantsMixin.java new file mode 100644 index 0000000..c54d024 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/frame/mixin/FrameWeightedVariantsMixin.java @@ -0,0 +1,45 @@ +package it.cosimo.vesuvio.frame.mixin; + +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; + +import it.cosimo.vesuvio.frame.ModelSprites; +import it.cosimo.vesuvio.frame.SpriteSet; +import net.minecraft.client.renderer.block.dispatch.BlockStateModel; +import net.minecraft.client.renderer.block.dispatch.WeightedVariants; +import net.minecraft.client.renderer.texture.SpriteContents; +import net.minecraft.util.random.Weighted; +import net.minecraft.util.random.WeightedList; + +/** The animated sprites of a weighted model: those of every variant it may pick. */ +@Mixin(WeightedVariants.class) +public abstract class FrameWeightedVariantsMixin implements ModelSprites.Holder { + @Shadow + @Final + private WeightedList list; + + @Unique + private volatile SpriteContents @Nullable [] vesuvio$animated; + @Unique + private volatile boolean vesuvio$unknown; + + @Override + public SpriteContents @Nullable [] vesuvio$animatedSprites() { + SpriteContents[] sprites = this.vesuvio$animated; + if (sprites == null && !this.vesuvio$unknown) { + SpriteSet set = new SpriteSet(); + for (Weighted entry : this.list.unwrap()) { + if (!ModelSprites.addModel(entry.value(), set)) { + this.vesuvio$unknown = true; + return null; + } + } + sprites = set.toArray(); + this.vesuvio$animated = sprites; + } + return sprites; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/leaves/LeafCulling.java b/src/main/java/it/cosimo/vesuvio/leaves/LeafCulling.java new file mode 100644 index 0000000..9fbe480 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/leaves/LeafCulling.java @@ -0,0 +1,221 @@ +package it.cosimo.vesuvio.leaves; + +import java.util.concurrent.atomic.LongAdder; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.ui.VesuvioStats; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.SectionPos; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.LeavesBlock; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Leaf face culling. With "cutout leaves" on (fancy leaves), vanilla draws every face of a leaf block that touches + * another leaf block, so a canopy is built of stacked see-through planes all the way through. Most of the inner ones + * are never seen: to reach them a ray has to pass several leaf textures first. Here a leaf face that touches another + * leaf is dropped when the leaf is buried in that direction, i.e. the next blocks further along are leaves too (or + * full opaque blocks); the outer layers, which are what you actually see, stay. Only the faces the model marks with a + * cullface are affected (all of a vanilla leaf block's faces), and only during section compilation. The hook sits on + * {@code Block.shouldRenderFace}, which both vanilla's block renderer and Fabric API's Indigo renderer call, so it + * works with or without Fabric API. + * + *

Modes ({@link #MODE}): 0 off (vanilla), 1 smart (default: the neighbour and the two blocks after it must be + * leaves/opaque, keeps three planes from every surface), 2 aggressive (neighbour and one more, like OptiFine's Smart + * Leaves / CullLeaves, keeps two planes), 3 all (any leaf neighbour, like fast graphics but keeping the cutout look; + * trees turn into hollow shells). + * + *

Threads: {@link #cull} runs on the section compile workers and only reads the {@code RenderSectionRegion} it is + * given (it reaches at most 3 blocks from the block being compiled, well inside the region's one-section margin); + * {@link #poll} and {@link #blockChanged} run on the render thread. + */ +public final class LeafCulling { + public static final int OFF = 0; + public static final int SMART = 1; + public static final int AGGRESSIVE = 2; + public static final int ALL = 3; + + /** 0 off, 1 smart (default), 2 aggressive, 3 all. Live: changing it rebuilds the loaded chunk meshes. */ + public static final VesuvioConfig.Number MODE = VesuvioConfig.number("rendering", "leafCulling", OFF, ALL, SMART, false); + + /** The mode the meshes are being built with; changes only in {@link #poll}, together with a full rebuild. */ + private static volatile int active = OFF; + + private static final LongAdder CHECKED = new LongAdder(); + private static final LongAdder CULLED = new LongAdder(); + private static final BlockPos.MutableBlockPos MAIN_PROBE = new BlockPos.MutableBlockPos(); + + private LeafCulling() { + } + + /** Creates the setting and its counter (called from Vesuvio.onInitializeClient). */ + public static void init() { + active = wanted(); + // F3, the settings screen's box, the stats log and the benchmark's counter table. + VesuvioStats.register("leaf faces culled %", LeafCulling::culledPercent); + } + + private static int wanted() { + return VesuvioConfig.ENABLED.on() ? MODE.get() : OFF; + } + + /** The mode the current meshes use (0 when the master switch is off). */ + public static int activeMode() { + return active; + } + + /** + * Counter: leaf faces facing another leaf that vanilla would have drawn, in the sections compiled since the + * mode last changed (sections compiled more than once count again). + */ + public static long leafFacesChecked() { + return CHECKED.sum(); + } + + /** Counter: how many of {@link #leafFacesChecked()} were culled. */ + public static long leafFacesCulled() { + return CULLED.sum(); + } + + /** Counter: share of leaf-against-leaf faces culled, 0..100 (0 before any leaf was compiled). */ + public static int culledPercent() { + long checked = CHECKED.sum(); + return checked == 0 ? 0 : (int) (CULLED.sum() * 100 / checked); + } + + /** + * Render thread, once a frame: when the setting or the master switch changed, switch mode and rebuild every + * loaded section, as vanilla does when a graphics option changes. + */ + public static void poll() { + int mode = wanted(); + if (mode != active) { + active = mode; + CHECKED.reset(); + CULLED.reset(); + Minecraft.getInstance().levelExtractor.allChanged(); + } + } + + /** + * Section compile thread. Called only where vanilla decided to draw the face of {@code state} turned towards + * {@code direction} (so the neighbour neither hides it nor makes vanilla skip it). True drops the face. + * + * @param neighborPos the neighbour's position (the renderer's scratch position: read only) + * @param probe a position this call may overwrite, owned by the calling renderer + */ + public static boolean cull(BlockGetter level, BlockState state, BlockState neighbor, BlockPos neighborPos, + Direction direction, BlockPos.MutableBlockPos probe) { + int mode = active; + if (mode == OFF || !(state.getBlock() instanceof LeavesBlock) || !(neighbor.getBlock() instanceof LeavesBlock)) { + return false; + } + CHECKED.increment(); + if (mode != ALL) { + int depth = mode == SMART ? 3 : 2; + probe.set(neighborPos); + for (int k = 2; k <= depth; k++) { + probe.move(direction); + if (!buries(level.getBlockState(probe))) { + return false; + } + } + } + CULLED.increment(); + return true; + } + + /** Whether a block hides what is behind it as well as a leaf does, for the "buried" test. */ + private static boolean buries(BlockState state) { + return state.getBlock() instanceof LeavesBlock || state.isSolidRender(); + } + + /** + * Render thread, after vanilla marked the sections within one block of {@code pos} for rebuilding. A leaf face's + * culling also depends on blocks 2 and 3 steps away, so when a block starts or stops burying, the sections of + * the leaves 2..3 blocks away along each axis are rebuilt too (only those vanilla's one-block margin misses). + */ + public static void blockChanged(ClientLevel level, BlockPos pos, BlockState oldState, BlockState newState) { + int mode = active; + if (mode == OFF || mode == ALL || buries(oldState) == buries(newState)) { + return; + } + int depth = mode == SMART ? 3 : 2; + BlockPos.MutableBlockPos probe = MAIN_PROBE; + for (Direction d : DIRECTIONS) { + for (int k = 2; k <= depth; k++) { + probe.setWithOffset(pos, d.getStepX() * k, d.getStepY() * k, d.getStepZ() * k); + int sx = SectionPos.blockToSectionCoord(probe.getX()); + int sy = SectionPos.blockToSectionCoord(probe.getY()); + int sz = SectionPos.blockToSectionCoord(probe.getZ()); + // pos + d lies between pos and the probe: if they share a section, vanilla's 3x3x3 already marked it. + if (sx == SectionPos.blockToSectionCoord(pos.getX() + d.getStepX()) + && sy == SectionPos.blockToSectionCoord(pos.getY() + d.getStepY()) + && sz == SectionPos.blockToSectionCoord(pos.getZ() + d.getStepZ())) { + continue; // vanilla already marked this section + } + if (level.getBlockState(probe).getBlock() instanceof LeavesBlock) { + Minecraft.getInstance().levelExtractor.setSectionDirty(sx, sy, sz); + } + } + } + } + + /** + * Section compile thread: the leaf block about to be tesselated. Whichever block renderer meshes it (vanilla's, + * or Fabric API's Indigo, which replaces it in SectionCompiler but still asks {@code Block.shouldRenderFace}), + * the face test then knows where the leaf is. Only leaf blocks are recorded. + */ + public static void beginBlock(BlockGetter region, BlockPos pos, BlockState state) { + // The type test first: most blocks are not leaves, and it is a plain read where the mode is a volatile one. + if (!(state.getBlock() instanceof LeavesBlock) || active == OFF) { + return; + } + CompileContext ctx = CONTEXT.get(); + ctx.level = region; + ctx.state = state; + ctx.pos.set(pos); + } + + /** + * Section compile thread, end of a compile: forget the region. Always, even with the mode now off: it may have been + * switched off during this compile, after {@link #beginBlock} recorded a leaf. + */ + public static void endCompile() { + CompileContext ctx = CONTEXT.get(); + ctx.level = null; + ctx.state = null; + } + + /** + * {@code Block.shouldRenderFace} said "draw": true drops the face. Acts only inside a section compile, for the + * leaf block recorded by {@link #beginBlock} (the only blocks that pass {@code state} here are the one being + * tesselated, so the identity check ties the call to its position). + */ + public static boolean cullAtCompile(BlockState state, BlockState neighbor, Direction direction) { + if (!(state.getBlock() instanceof LeavesBlock) || !(neighbor.getBlock() instanceof LeavesBlock) || active == OFF) { + return false; + } + CompileContext ctx = CONTEXT.get(); + if (ctx.state != state || ctx.level == null) { + return false; + } + ctx.neighbor.setWithOffset(ctx.pos, direction); + return cull(ctx.level, state, neighbor, ctx.neighbor, direction, ctx.probe); + } + + private static final class CompileContext { + BlockGetter level; + BlockState state; + final BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + final BlockPos.MutableBlockPos neighbor = new BlockPos.MutableBlockPos(); + final BlockPos.MutableBlockPos probe = new BlockPos.MutableBlockPos(); + } + + private static final ThreadLocal CONTEXT = ThreadLocal.withInitial(CompileContext::new); + + private static final Direction[] DIRECTIONS = Direction.values(); +} diff --git a/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafBlockChangeMixin.java b/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafBlockChangeMixin.java new file mode 100644 index 0000000..f354439 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafBlockChangeMixin.java @@ -0,0 +1,25 @@ +package it.cosimo.vesuvio.leaves.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.leaves.LeafCulling; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.state.BlockState; + +/** + * ClientLevel.sendBlockUpdated: vanilla rebuilds the sections within one block of a changed block; leaf culling + * looks up to three blocks ahead, so {@link LeafCulling#blockChanged} adds the few sections that margin misses. + * Does nothing while leaf culling is off. + */ +@Mixin(ClientLevel.class) +public abstract class LeafBlockChangeMixin { + @Inject(method = "sendBlockUpdated", at = @At("TAIL")) + private void vesuvio$rebuildLeafLookAhead(BlockPos pos, BlockState old, BlockState current, int updateFlags, + CallbackInfo ci) { + LeafCulling.blockChanged((ClientLevel) (Object) this, pos, old, current); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafCompileMixin.java b/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafCompileMixin.java new file mode 100644 index 0000000..978425e --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafCompileMixin.java @@ -0,0 +1,37 @@ +package it.cosimo.vesuvio.leaves.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import com.llamalad7.mixinextras.injector.ModifyReceiver; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.leaves.LeafCulling; +import net.minecraft.client.renderer.chunk.RenderSectionRegion; +import net.minecraft.client.renderer.chunk.SectionCompiler; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.state.BlockState; + +/** + * SectionCompiler.compile: tells {@link LeafCulling} which block is about to be tesselated. The hook is the block's + * seed lookup, an argument of the tesselate call: Fabric API redirects that call to its own renderer but the argument + * is still computed here, right before it. A ModifyReceiver (returning the same state) runs at the same moment as a + * wrapper would, without boxing the seed or wrapping the region local. + */ +@Mixin(SectionCompiler.class) +public abstract class LeafCompileMixin { + @ModifyReceiver(method = "compile", at = @At(value = "INVOKE", + target = "Lnet/minecraft/world/level/block/state/BlockState;getSeed(Lnet/minecraft/core/BlockPos;)J")) + private BlockState vesuvio$recordLeaf(BlockState state, BlockPos pos, + @Local(argsOnly = true) RenderSectionRegion region) { + LeafCulling.beginBlock(region, pos, state); + return state; + } + + @Inject(method = "compile", at = @At("RETURN")) + private void vesuvio$forgetRegion(CallbackInfoReturnable cir) { + LeafCulling.endCompile(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafFaceCullMixin.java b/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafFaceCullMixin.java new file mode 100644 index 0000000..801d9a3 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafFaceCullMixin.java @@ -0,0 +1,27 @@ +package it.cosimo.vesuvio.leaves.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.leaves.LeafCulling; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Block.shouldRenderFace, the face test both vanilla's block renderer and Fabric API's Indigo renderer use: a face + * vanilla would draw may still be dropped by {@link LeafCulling#cullAtCompile} (only inside a section compile, only + * leaves). Vanilla's "hidden" is never changed, and with leaf culling off the answer is returned untouched. + */ +@Mixin(Block.class) +public abstract class LeafFaceCullMixin { + @ModifyReturnValue(method = "shouldRenderFace(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/core/Direction;)Z", + at = @At("RETURN")) + private static boolean vesuvio$cullBuriedLeaves(boolean draw, @Local(argsOnly = true, ordinal = 0) BlockState state, + @Local(argsOnly = true, ordinal = 1) BlockState neighborState, @Local(argsOnly = true) Direction direction) { + return draw && !LeafCulling.cullAtCompile(state, neighborState, direction); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafSettingPollMixin.java b/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafSettingPollMixin.java new file mode 100644 index 0000000..40a89ad --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/leaves/mixin/LeafSettingPollMixin.java @@ -0,0 +1,25 @@ +package it.cosimo.vesuvio.leaves.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.leaves.LeafCulling; +import net.minecraft.client.Camera; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.renderer.extract.LevelExtractor; + +/** + * LevelExtractor.extract (render thread, once a frame, before the sections are scheduled): applies a change of + * the leaf culling setting or of the master switch by rebuilding every loaded section ({@code allChanged}, as + * vanilla does for graphics options). One int compare per frame otherwise. + */ +@Mixin(LevelExtractor.class) +public abstract class LeafSettingPollMixin { + @Inject(method = "extract", at = @At("HEAD")) + private void vesuvio$applyLeafCulling(DeltaTracker deltaTracker, Camera camera, float worldPartialTicks, + CallbackInfo ci) { + LeafCulling.poll(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/LightLock.java b/src/main/java/it/cosimo/vesuvio/memory/LightLock.java new file mode 100644 index 0000000..f6ea020 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/LightLock.java @@ -0,0 +1,71 @@ +package it.cosimo.vesuvio.memory; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.lang.reflect.Field; + +import net.minecraft.ReportedException; +import net.minecraft.util.ThreadingDetector; +import net.minecraft.world.level.chunk.PalettedContainer; + +/** + * The lock-free stand-in for PalettedContainer's ThreadingDetector (idea from FerriteCore's "small threading + * detector", own code). Vanilla gives every chunk-section container a ThreadingDetector holding a Semaphore and a + * ReentrantLock (~128 bytes, 5 objects) only to crash with a clear report when two threads write one container at + * once. This keeps that check with one field in the container: the thread that holds it, swapped with one atomic + * operation. Same outcome on misuse (both threads get the "Accessing PalettedContainer from multiple threads" + * crash, with both stack traces); same behaviour otherwise. + */ +public final class LightLock { + /** Name of the field PalettedContainerMixin adds: null (free), the owning Thread, or a ReportedException. */ + public static final String FIELD = "vesuvio$holder"; + private static final VarHandle HOLDER = find(); + /** False if the field can't be found: containers then keep vanilla's detector (PalettedContainerMixin). */ + public static final boolean AVAILABLE = HOLDER != null; + /** + * The detector every container gets when LightLock is used: one shared object, never locked, that only means + * "use LightLock". (Mixin rejects null from a constructor redirect at run time, so a marker stands in for "none".) + */ + public static final ThreadingDetector MARKER = new ThreadingDetector("vesuvio-light-lock"); + + private LightLock() { + } + + private static VarHandle find() { + try { + MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(PalettedContainer.class, MethodHandles.lookup()); + for (Field f : PalettedContainer.class.getDeclaredFields()) { + // Mixin keeps a @Unique name unless it clashes, in which case it only adds a suffix. + if (f.getName().startsWith(FIELD) && f.getType() == Object.class) { + return lookup.findVarHandle(PalettedContainer.class, f.getName(), Object.class); + } + } + it.cosimo.vesuvio.Vesuvio.LOGGER.warn("Vesuvio: light chunk thread checks unavailable, using vanilla's"); + } catch (ReflectiveOperationException | RuntimeException e) { + it.cosimo.vesuvio.Vesuvio.LOGGER.warn("Vesuvio: light chunk thread checks unavailable, using vanilla's", e); + } + return null; + } + + /** Vanilla ThreadingDetector.checkAndLock. */ + public static void lock(PalettedContainer container) { + Thread me = Thread.currentThread(); + Object previous = HOLDER.compareAndExchange(container, (Object) null, (Object) me); + if (previous != null) { + // Another thread is inside (or it is a bug in this thread). Like vanilla: build the report with both + // threads' stacks, make the holder's release throw it too, and throw it here. + ReportedException failure = ThreadingDetector.makeThreadingException("PalettedContainer", + previous instanceof Thread t ? t : null); + HOLDER.compareAndSet(container, previous, (Object) failure); + throw failure; + } + } + + /** Vanilla ThreadingDetector.checkAndUnlock. */ + public static void unlock(PalettedContainer container) { + Object previous = HOLDER.getAndSet(container, (Object) null); + if (previous instanceof ReportedException failure) { + throw failure; + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/MemoryAdvisor.java b/src/main/java/it/cosimo/vesuvio/memory/MemoryAdvisor.java new file mode 100644 index 0000000..2dcce36 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/MemoryAdvisor.java @@ -0,0 +1,321 @@ +package it.cosimo.vesuvio.memory; + +import java.lang.management.GarbageCollectorMXBean; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryPoolMXBean; +import java.lang.management.MemoryType; +import java.lang.management.MemoryUsage; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import com.sun.management.GcInfo; + +import it.cosimo.vesuvio.Vesuvio; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +/** + * The memory check: how much memory the game really needs, against how much the launcher lets it use (the + * "maximum memory" setting, -Xmx) and how much the computer has. Too little makes the game collect garbage over + * and over (stutter, then a crash); far too much leaves the rest of the computer short (on a Mac the graphics + * share the same memory), so macOS or Windows starts swapping, which stutters too. In between, more makes no + * difference: the garbage collector's pauses do not grow with the setting. + * + *

The game's real need is the heap still in use right after a collection (what survives: the world, models, + * textures' CPU side), read from the collector's report every 5 s; the advice is twice its peak plus 1 GB, at least 3 GB, rounded up to + * whole gigabytes. Nothing here changes how the game runs: the launcher fixes the maximum before the game + * starts, so the check can only say what to set (Prism: Edit instance > Settings > Java > Maximum memory). + */ +public final class MemoryAdvisor { + public enum Verdict { + /** Not enough time in a world yet to know the need. */ + MEASURING, + OK, + /** The game is close to its maximum after collections: raise the setting. */ + TOO_LOW, + /** The maximum is more than the computer can spare: lower the setting. */ + TOO_HIGH + } + + private static final long MB = 1024L * 1024L; + private static final long GB = 1024L * MB; + private static final long SAMPLE_NS = 5_000_000_000L; + /** Samples (5 s each) taken in a world before the need counts as known: 2 minutes. */ + private static final int SAMPLES_TO_KNOW = 24; + /** Live heap after a collection above this share of the maximum: too low. */ + private static final double TIGHT = 0.80; + + private static final long MAX_HEAP = Runtime.getRuntime().maxMemory(); + private static final long RAM = physicalMemory(); + private static final boolean UNIFIED = isAppleSilicon(); + private static final Set HEAP_POOLS = heapPools(); + private static final List OLD_POOLS = oldPools(); + + private static long lastSample; + private static int samples; + /** Samples per window: 5 minutes. */ + private static final int WINDOW = 60; + /** + * The game's need: the highest of the windows' floors. The heap after a young collection still holds old + * garbage until the next full or concurrent cleanup, so one reading overstates the need, and more so the + * higher the maximum; the lowest reading of a 5-minute window is close to what really lives, and the highest + * such floor covers the busiest part of the session. + */ + private static long peakLive; + private static long windowMin = Long.MAX_VALUE; + private static int windowSamples; + /** Times the live heap after a collection was above {@link #TIGHT} of the maximum. */ + private static int tightSamples; + private static long lastGcCount = -1; + private static long lastGcMs; + /** Collection pauses in the last 5 s, for F3. */ + private static long gcCountWindow; + private static long gcMsWindow; + private static boolean told; + + private MemoryAdvisor() { + } + + /** Called from MemoryBoost.init: one log line with the facts the advice rests on. */ + static void init() { + Vesuvio.LOGGER.info("Vesuvio memory check: maximum {} of {} on this computer ({}), garbage collector {}, JVM options: {}", + size(MAX_HEAP), RAM > 0 ? size(RAM) : "unknown", UNIFIED ? "Apple silicon, memory shared with the graphics" + : System.getProperty("os.name", "?"), + collectorNames(), jvmOptions()); + } + + /** End of every client tick (VesuvioUi.afterTick). Samples every 5 s while in a world. */ + public static void tick(Minecraft mc) { + long now = System.nanoTime(); + if (now - lastSample < SAMPLE_NS) { + return; + } + lastSample = now; + long count = 0; + long ms = 0; + for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) { + count += Math.max(0, gc.getCollectionCount()); + ms += Math.max(0, gc.getCollectionTime()); + } + if (lastGcCount >= 0) { + gcCountWindow = count - lastGcCount; + gcMsWindow = ms - lastGcMs; + } + lastGcCount = count; + lastGcMs = ms; + if (mc.level == null) { + return; + } + long old = oldAfterCleanup(); + long live = liveAfterCollection(); + if (live <= 0) { + // ZGC's minor cycles report empty pools: fall back to what the old generation kept. + live = old; + } + if (live <= 0) { + return; + } + samples++; + windowMin = Math.min(windowMin, live); + if (++windowSamples >= WINDOW) { + peakLive = Math.max(peakLive, windowMin); + windowMin = Long.MAX_VALUE; + windowSamples = 0; + } + // Count a tight reading once per new collection, not once per sample of the same one. + if (live > MAX_HEAP * TIGHT && gcCountWindow > 0) { + tightSamples++; + } + Verdict v = verdict(); + if (!told && (v == Verdict.TOO_LOW || v == Verdict.TOO_HIGH)) { + told = true; + Vesuvio.LOGGER.info("Vesuvio memory check: {}", advice().getString()); + mc.gui.hud.getChat().addClientSystemMessage(Component.translatable("vesuvio.memory.chat", advice())); + } + } + + public static Verdict verdict() { + if (tightSamples >= 3) { + return Verdict.TOO_LOW; + } + if (RAM > 0 && MAX_HEAP > spare()) { + return Verdict.TOO_HIGH; + } + return samples < SAMPLES_TO_KNOW ? Verdict.MEASURING : Verdict.OK; + } + + /** The need as known so far: the highest window floor, or the current window's floor before the first ends. */ + private static long need() { + return peakLive > 0 ? peakLive : windowMin == Long.MAX_VALUE ? 0 : windowMin; + } + + /** The maximum to set, in whole gigabytes. */ + public static long recommendedGb() { + long need = Math.max(3 * GB, need() * 2 + GB); + long gb = (need + GB - 1) / GB; + if (RAM > 0) { + gb = Math.min(gb, Math.max(2, spare() / GB)); + } + return gb; + } + + /** One line for the settings screen's button. */ + public static Component status() { + String use = need() > 0 ? size(need()) : "?"; + return switch (verdict()) { + case MEASURING -> Component.translatable("vesuvio.memory.status.measuring", size(MAX_HEAP)); + case OK -> Component.translatable("vesuvio.memory.status.ok", use, size(MAX_HEAP)); + case TOO_LOW -> Component.translatable("vesuvio.memory.status.low", use, size(MAX_HEAP)); + case TOO_HIGH -> Component.translatable("vesuvio.memory.status.high", size(MAX_HEAP)); + }; + } + + /** What to do, in a sentence or two. */ + public static Component advice() { + long rec = recommendedGb(); + String range = rec + "-" + Math.max(rec, Math.max(2, spare() / GB)) + " GB"; + return switch (verdict()) { + case MEASURING -> Component.translatable("vesuvio.memory.advice.measuring"); + case OK -> Component.translatable("vesuvio.memory.advice.ok", size(MAX_HEAP), range); + case TOO_LOW -> Component.translatable("vesuvio.memory.advice.low", size(need()), size(MAX_HEAP), rec + " GB"); + case TOO_HIGH -> Component.translatable("vesuvio.memory.advice.high", size(MAX_HEAP), + RAM > 0 ? size(RAM) : "?", rec + " GB"); + }; + } + + /** The facts behind the advice, for the settings screen's box and F3. */ + public static String facts() { + return String.format(Locale.ROOT, "in use after the last cleanup %s (need %s), maximum %s, computer %s, cleanups last 5 s: %d (%d ms)", + size(Math.max(0, liveAfterCollection())), size(need()), size(MAX_HEAP), RAM > 0 ? size(RAM) : "?", + gcCountWindow, gcMsWindow); + } + + /** Most memory the game may take before the computer runs short: half on Apple silicon, else all but 4 GB. */ + private static long spare() { + if (RAM <= 0) { + return Long.MAX_VALUE; + } + long spare = UNIFIED ? RAM / 2 : Math.max(RAM / 2, RAM - 4 * GB); + // A computer with lots of memory still gains nothing past ~12 GB for one player; this only caps the advice + // and the warning, never below 2 GB. + return Math.max(2 * GB, spare); + } + + /** + * Heap in use right after the most recent collection, young or full (every heap pool, from the collector's own + * report): what survived, plus old garbage a young collection leaves for later, so it is never below the real + * need. -1 when no collection has run yet. + */ + private static long liveAfterCollection() { + long newest = -1; + long live = -1; + for (GarbageCollectorMXBean bean : ManagementFactory.getGarbageCollectorMXBeans()) { + if (!(bean instanceof com.sun.management.GarbageCollectorMXBean gc)) { + continue; + } + GcInfo info = gc.getLastGcInfo(); + if (info == null || info.getEndTime() < newest) { + continue; + } + long sum = 0; + for (Map.Entry e : info.getMemoryUsageAfterGc().entrySet()) { + if (HEAP_POOLS.contains(e.getKey())) { + sum += e.getValue().getUsed(); + } + } + newest = info.getEndTime(); + live = sum; + } + return live; + } + + /** What the long-lived heap pools held after the last collection that cleaned them; 0 when none has run yet. */ + private static long oldAfterCleanup() { + long sum = 0; + for (MemoryPoolMXBean pool : OLD_POOLS) { + MemoryUsage u = pool.getCollectionUsage(); + if (u != null) { + sum += u.getUsed(); + } + } + return sum; + } + + /** Heap pools for long-lived objects: G1 "Old Gen", ZGC "Old Generation", Parallel/Serial "Old Gen"/"Tenured". */ + private static List oldPools() { + List old = new ArrayList<>(); + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) { + String name = pool.getName().toLowerCase(Locale.ROOT); + if (pool.getType() == MemoryType.HEAP && (name.contains("old") || name.contains("tenured"))) { + old.add(pool); + } + } + return old; + } + + /** Names of the heap's pools (the collectors' reports also list code cache and metaspace). */ + private static Set heapPools() { + Set names = new HashSet<>(); + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) { + if (pool.getType() == MemoryType.HEAP) { + names.add(pool.getName()); + } + } + return names; + } + + private static long physicalMemory() { + try { + if (ManagementFactory.getOperatingSystemMXBean() instanceof com.sun.management.OperatingSystemMXBean os) { + return os.getTotalMemorySize(); + } + } catch (RuntimeException | LinkageError e) { + Vesuvio.LOGGER.debug("Vesuvio: could not read the computer's memory size", e); + } + return -1; + } + + private static boolean isAppleSilicon() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + return os.contains("mac") && (arch.contains("aarch64") || arch.contains("arm")); + } + + private static String collectorNames() { + List names = new ArrayList<>(); + for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) { + names.add(gc.getName()); + } + return String.join(", ", names); + } + + /** Memory and garbage collector options only (-Xmx, -Xms, -XX:...), nothing that could name the user. */ + private static String jvmOptions() { + List out = new ArrayList<>(); + try { + for (String arg : ManagementFactory.getRuntimeMXBean().getInputArguments()) { + if (arg.startsWith("-Xm") || arg.startsWith("-Xs") || arg.startsWith("-XX:")) { + out.add(arg); + } + } + } catch (RuntimeException e) { + return "?"; + } + return out.isEmpty() ? "none" : String.join(" ", out); + } + + static String size(long bytes) { + return bytes >= GB ? String.format(Locale.ROOT, "%.1f GB", bytes / (double) GB) : (bytes / MB) + " MB"; + } + + /** For the settings screen: null when there is nothing to warn about. */ + public static @Nullable Component warning() { + Verdict v = verdict(); + return v == Verdict.TOO_LOW || v == Verdict.TOO_HIGH ? advice() : null; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/MemoryBoost.java b/src/main/java/it/cosimo/vesuvio/memory/MemoryBoost.java new file mode 100644 index 0000000..520c78d --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/MemoryBoost.java @@ -0,0 +1,70 @@ +package it.cosimo.vesuvio.memory; + +import it.cosimo.vesuvio.VesuvioConfig; + +/** + * Memory and load-time savings (page "memory"). Every setting here acts on objects made while the game starts or + * while chunks load, so each one needs a restart: {@link MemoryMixinPlugin} leaves the matching mixins out + * entirely when a setting (or the master switch) is off at start-up, so the game runs vanilla code. + * + *

No setting changes anything that can be seen: they only make equal, immutable objects shared instead of + * copied, or replace a debug lock with a smaller one that catches the same mistakes. + */ +public final class MemoryBoost { + // Keys are compile-time constants so MemoryMixinPlugin can read them without loading this class early. + public static final String KEY_THREAD_CHECKS = "memoryLightThreadChecks"; + public static final String KEY_SHAPES = "memoryShareBlockShapes"; + public static final String KEY_STATE_ARRAYS = "memoryShareStateTables"; + public static final String KEY_IDENTIFIERS = "memoryShareIdNamespaces"; + public static final boolean DEFAULT_THREAD_CHECKS = true; + public static final boolean DEFAULT_SHAPES = true; + public static final boolean DEFAULT_STATE_ARRAYS = true; + public static final boolean DEFAULT_IDENTIFIERS = true; + + /** Chunk data (PalettedContainer): a 4-byte owner field instead of a ~128-byte lock trio per container. */ + public static final VesuvioConfig.Toggle LIGHT_THREAD_CHECKS = VesuvioConfig.toggle("memory", KEY_THREAD_CHECKS, + DEFAULT_THREAD_CHECKS, true); + /** Block shapes: one shared object per distinct shape; wall/fence/pane shape tables computed once per kind. */ + public static final VesuvioConfig.Toggle SHARE_BLOCK_SHAPES = VesuvioConfig.toggle("memory", KEY_SHAPES, + DEFAULT_SHAPES, true); + /** Block state tables: equal face-support and face-occlusion arrays shared between states. */ + public static final VesuvioConfig.Toggle SHARE_STATE_TABLES = VesuvioConfig.toggle("memory", KEY_STATE_ARRAYS, + DEFAULT_STATE_ARRAYS, true); + /** Identifiers: one shared "minecraft" namespace string instead of a copy per parsed id. */ + public static final VesuvioConfig.Toggle SHARE_ID_NAMESPACES = VesuvioConfig.toggle("memory", KEY_IDENTIFIERS, + DEFAULT_IDENTIFIERS, true); + + // Counters for the settings screen / benchmark (written at start-up only, on the thread that builds blocks). + /** Block shapes replaced by an equal shape that already existed. */ + public static int shapesShared; + /** Wall/fence/pane/bars shape tables taken from an earlier block of the same kind instead of computed. */ + public static int shapeTablesReused; + /** Block state face tables (support flags, occlusion faces) replaced by an equal shared one. */ + public static int stateTablesShared; + + private MemoryBoost() { + } + + /** Called from Vesuvio.onInitializeClient: makes the settings and drops the start-up lookup tables. */ + public static void init() { + MemoryAdvisor.init(); + // F3, the settings screen's box, the stats log and the benchmark's counter table (start-up sharing only). + it.cosimo.vesuvio.ui.VesuvioStats.register("block shapes shared", () -> shapesShared); + it.cosimo.vesuvio.ui.VesuvioStats.register("memory saved (KB)", () -> (int) (estimatedBytesSaved() / 1024)); + // Every vanilla block (and the blocks of mods that register in their main entry point) exists by now; + // the lookup tables would only keep ~100 KB alive. A block made later just starts a new table. + // (Only touch the helper classes whose mixins were applied, i.e. whose setting was on at start-up.) + if (SHARE_BLOCK_SHAPES.on()) { + ShapeDedup.release(); + } + if (SHARE_STATE_TABLES.on()) { + StateTableDedup.release(); + } + } + + /** Rough bytes saved so far by the start-up sharing (for the settings screen), not counting chunk data. */ + public static long estimatedBytesSaved() { + // ~450 B for a shape with its coordinate lists, ~40 B for a face table. + return shapesShared * 450L + stateTablesShared * 40L; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/MemoryMixinPlugin.java b/src/main/java/it/cosimo/vesuvio/memory/MemoryMixinPlugin.java new file mode 100644 index 0000000..280fa54 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/MemoryMixinPlugin.java @@ -0,0 +1,83 @@ +package it.cosimo.vesuvio.memory; + +import java.util.List; +import java.util.Set; + +import org.objectweb.asm.tree.ClassNode; +import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; +import org.spongepowered.asm.mixin.extensibility.IMixinInfo; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.VesuvioMixinPlugin; +import it.cosimo.vesuvio.Vesuvio; +import net.fabricmc.loader.api.FabricLoader; + +/** + * Mixin plugin of vesuvio.memory.mixins.json. Does what {@link VesuvioMixinPlugin} does (the disabledMixins kill + * list) and also leaves out every mixin whose setting, or the master switch, is off at start-up: these + * optimizations act while things are built, so when off they must not be in the game at all. + */ +public final class MemoryMixinPlugin implements IMixinConfigPlugin { + private final VesuvioMixinPlugin base = new VesuvioMixinPlugin(); + /** FerriteCore / ModernFix change the same code (and would clash with these mixins): let them do it. */ + private boolean otherMemoryMod; + + @Override + public void onLoad(String mixinPackage) { + base.onLoad(mixinPackage); + FabricLoader loader = FabricLoader.getInstance(); + otherMemoryMod = loader.isModLoaded("ferritecore") || loader.isModLoaded("modernfix"); + if (otherMemoryMod) { + Vesuvio.LOGGER.info("Vesuvio: FerriteCore or ModernFix installed, Vesuvio's memory savings stay off"); + } + } + + @Override + public String getRefMapperConfig() { + return null; + } + + @Override + public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + if (otherMemoryMod || !base.shouldApplyMixin(targetClassName, mixinClassName)) { + return false; + } + String simple = mixinClassName.substring(mixinClassName.lastIndexOf('.') + 1); + boolean on = switch (simple) { + case "PalettedContainerMixin" -> on(MemoryBoost.KEY_THREAD_CHECKS, MemoryBoost.DEFAULT_THREAD_CHECKS); + case "BlockShapeTableMixin", "WallBlockMixin", "CrossCollisionBlockMixin", "StateCacheShapeMixin", + "BlockStateOcclusionShapeMixin", "VoxelShapeAccessor", "BitSetDiscreteVoxelShapeAccessor" -> + on(MemoryBoost.KEY_SHAPES, MemoryBoost.DEFAULT_SHAPES); + case "StateCacheSupportMixin", "BlockStateFaceTableMixin" -> + on(MemoryBoost.KEY_STATE_ARRAYS, MemoryBoost.DEFAULT_STATE_ARRAYS); + case "IdentifierMixin" -> on(MemoryBoost.KEY_IDENTIFIERS, MemoryBoost.DEFAULT_IDENTIFIERS); + default -> true; + }; + if (!on) { + Vesuvio.LOGGER.info("Vesuvio: {} not applied (its memory setting or the master switch is off)", simple); + } + return on; + } + + private static boolean on(String key, boolean fallback) { + return Boolean.parseBoolean(VesuvioConfig.raw("enabled", "true").trim()) + && Boolean.parseBoolean(VesuvioConfig.raw(key, Boolean.toString(fallback)).trim()); + } + + @Override + public void acceptTargets(Set myTargets, Set otherTargets) { + } + + @Override + public List getMixins() { + return null; + } + + @Override + public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) { + } + + @Override + public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) { + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/ShapeDedup.java b/src/main/java/it/cosimo/vesuvio/memory/ShapeDedup.java new file mode 100644 index 0000000..c5888dc --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/ShapeDedup.java @@ -0,0 +1,244 @@ +package it.cosimo.vesuvio.memory; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import it.cosimo.vesuvio.memory.mixin.BitSetDiscreteVoxelShapeAccessor; +import it.cosimo.vesuvio.memory.mixin.VoxelShapeAccessor; +import it.unimi.dsi.fastutil.doubles.DoubleList; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.Property; +import net.minecraft.world.phys.shapes.ArrayVoxelShape; +import net.minecraft.world.phys.shapes.BitSetDiscreteVoxelShape; +import net.minecraft.world.phys.shapes.CubeVoxelShape; +import net.minecraft.world.phys.shapes.DiscreteVoxelShape; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Setting memoryShareBlockShapes (idea from FerriteCore's shape deduplication and ModernFix's wall-shape + * deduplication; own code). + * + *

Blocks build one VoxelShape per block state when they are made, and many of them are equal: the 26 wall + * blocks each build 2 x 162 shapes, each with its own coordinate lists and, once the game asks for face shapes, + * its own face slices (~900 bytes each, ~7.5 MB for walls alone); fences, panes and + * bars do the same on a smaller scale. {@link #intern} returns one shared shape for all shapes that are equal + * structurally (same class, same voxel grid bits and bounds, same coordinate list classes and exact + * values), so every method of the shared shape gives bit-for-bit the results the copy would have given. The only + * identity checks the game makes on shapes are against Shapes.block() and Shapes.empty(), which are never + * substituted. Shapes are immutable after they are built; their lazy face cache is filled the same way by any + * thread, as for the shapes vanilla already shares. + * + *

{@link #sharedTable} goes further for wall/fence/pane/bars blocks: their shape depends only on the block's + * class, the numbers passed to makeShapes and the state's property values, so the second block of a kind reuses + * the first one's shapes instead of computing them again (each wall shape is up to 4 Shapes.or calls with an + * optimize pass each), which also shortens start-up. + */ +public final class ShapeDedup { + private static final Direction.Axis[] AXES = Direction.Axis.values(); + private static final Object LOCK = new Object(); + private static Map shapes = new HashMap<>(); + private static Map, VoxelShape>> tables = new HashMap<>(); + + private ShapeDedup() { + } + + /** Drops the lookup tables (the shared shapes stay with the blocks that use them). */ + public static void release() { + synchronized (LOCK) { + shapes = new HashMap<>(); + tables = new HashMap<>(); + } + } + + /** The shared shape structurally equal to this one (or this one, which becomes the shared one). */ + public static VoxelShape intern(VoxelShape shape) { + if (shape == null || shape == Shapes.block() || shape == Shapes.empty()) { + return shape; + } + Class type = shape.getClass(); + if (type != ArrayVoxelShape.class && type != CubeVoxelShape.class) { + return shape; + } + if (grid(shape).getClass() != BitSetDiscreteVoxelShape.class) { + return shape; + } + Key key = new Key(shape); + synchronized (LOCK) { + VoxelShape known = shapes.putIfAbsent(key, shape); + if (known != null) { + MemoryBoost.shapesShared++; + return known; + } + } + return shape; + } + + /** Wraps a block's per-state shape calculator so its results are interned. */ + public static Function interning(Function calculator) { + return state -> intern(calculator.apply(state)); + } + + /** + * The state-to-shape function for a wall/fence-like block: from an earlier block of the same class, built with + * the same numbers and the same properties, when there is one; otherwise computed (vanilla) and remembered. + */ + public static Function sharedTable(Block block, List numbers, + Supplier> compute) { + Class type = block.getClass(); + if (!type.getName().startsWith("net.minecraft.")) { + // A mod's subclass could compute shapes from its own fields: leave it alone. + return compute.get(); + } + ImmutableList states = block.getStateDefinition().getPossibleStates(); + Property[] properties = block.getStateDefinition().getProperties().toArray(new Property[0]); + TableKey key = new TableKey(type, numbers, properties); + Map, VoxelShape> table; + synchronized (LOCK) { + table = tables.get(key); + } + if (table != null) { + ImmutableMap.Builder map = ImmutableMap.builderWithExpectedSize(states.size()); + for (BlockState state : states) { + VoxelShape shape = table.get(values(state, properties)); + if (shape == null) { + return compute.get(); + } + map.put(state, shape); + } + MemoryBoost.shapeTablesReused++; + return map.build()::get; + } + Function computed = compute.get(); + Map, VoxelShape> built = new HashMap<>(states.size() * 2); + for (BlockState state : states) { + VoxelShape shape = computed.apply(state); + if (shape == null) { + return computed; + } + built.put(values(state, properties), shape); + } + synchronized (LOCK) { + tables.putIfAbsent(key, built); + } + return computed; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static List values(BlockState state, Property[] properties) { + Object[] values = new Object[properties.length]; + for (int i = 0; i < properties.length; i++) { + values[i] = state.getValue((Property) properties[i]); + } + return Arrays.asList(values); + } + + private static DiscreteVoxelShape grid(VoxelShape shape) { + return ((VoxelShapeAccessor) shape).vesuvio$getShape(); + } + + private static int hash(VoxelShape shape) { + DiscreteVoxelShape grid = grid(shape); + int h = shape.getClass().getName().hashCode(); + for (Direction.Axis axis : AXES) { + h = 31 * h + grid.getSize(axis); + h = 31 * h + grid.firstFull(axis); + h = 31 * h + grid.lastFull(axis); + DoubleList coords = shape.getCoords(axis); + h = 31 * h + coords.getClass().getName().hashCode(); + for (int i = 0, n = coords.size(); i < n; i++) { + h = 31 * h + Double.hashCode(coords.getDouble(i)); + } + } + return 31 * h + ((BitSetDiscreteVoxelShapeAccessor) grid).vesuvio$getStorage().hashCode(); + } + + /** Same class, same grid (size, bounds, bits), same coordinate list classes and exact coordinates. */ + private static boolean same(VoxelShape a, VoxelShape b) { + if (a == b) { + return true; + } + if (a.getClass() != b.getClass()) { + return false; + } + DiscreteVoxelShape ga = grid(a); + DiscreteVoxelShape gb = grid(b); + if (ga.getClass() != gb.getClass()) { + return false; + } + for (Direction.Axis axis : AXES) { + if (ga.getSize(axis) != gb.getSize(axis) || ga.firstFull(axis) != gb.firstFull(axis) + || ga.lastFull(axis) != gb.lastFull(axis)) { + return false; + } + DoubleList ca = a.getCoords(axis); + DoubleList cb = b.getCoords(axis); + int n = ca.size(); + if (ca.getClass() != cb.getClass() || n != cb.size()) { + return false; + } + for (int i = 0; i < n; i++) { + if (Double.doubleToRawLongBits(ca.getDouble(i)) != Double.doubleToRawLongBits(cb.getDouble(i))) { + return false; + } + } + } + return ((BitSetDiscreteVoxelShapeAccessor) ga).vesuvio$getStorage() + .equals(((BitSetDiscreteVoxelShapeAccessor) gb).vesuvio$getStorage()); + } + + private static final class Key { + private final VoxelShape shape; + private final int hash; + + Key(VoxelShape shape) { + this.shape = shape; + this.hash = ShapeDedup.hash(shape); + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public boolean equals(Object o) { + return o instanceof Key k && k.hash == hash && same(shape, k.shape); + } + } + + /** Block class + makeShapes numbers + the exact Property objects (compared by identity), in order. */ + private record TableKey(Class type, List numbers, Property[] properties) { + @Override + public boolean equals(Object o) { + if (!(o instanceof TableKey k) || k.type != type || !k.numbers.equals(numbers) + || k.properties.length != properties.length) { + return false; + } + for (int i = 0; i < properties.length; i++) { + if (k.properties[i] != properties[i]) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + int h = type.hashCode() * 31 + numbers.hashCode(); + for (Property p : properties) { + h = h * 31 + System.identityHashCode(p); + } + return h; + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/StateTableDedup.java b/src/main/java/it/cosimo/vesuvio/memory/StateTableDedup.java new file mode 100644 index 0000000..0447f0d --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/StateTableDedup.java @@ -0,0 +1,73 @@ +package it.cosimo.vesuvio.memory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Setting memoryShareStateTables (idea from FerriteCore's block state cache deduplication; own code). Every block + * state keeps, from start-up, an 18-entry "is this face sturdy" table and, for partly occluding blocks (stairs, + * slabs, walls, fences...), a 6-entry table of its face occlusion shapes. Both are written once in the + * constructor/initCache and only read afterwards; there are only a few dozen distinct support tables among ~30,000 + * states, so states with equal tables share one array. + */ +public final class StateTableDedup { + private static final Object LOCK = new Object(); + private static Map support = new HashMap<>(); + private static Map, VoxelShape[]> faces = new HashMap<>(); + + private StateTableDedup() { + } + + public static void release() { + synchronized (LOCK) { + support = new HashMap<>(); + faces = new HashMap<>(); + } + } + + /** The shared array with the same flags (the array is never written after the state's cache is built). */ + public static boolean[] support(boolean[] flags) { + if (flags == null || flags.length > 56) { + return flags; + } + long key = (long) flags.length << 56; + for (int i = 0; i < flags.length; i++) { + if (flags[i]) { + key |= 1L << i; + } + } + synchronized (LOCK) { + boolean[] known = support.putIfAbsent(key, flags); + if (known != null) { + MemoryBoost.stateTablesShared++; + return known; + } + } + return flags; + } + + /** The shared array holding the same six face shapes (same objects, in the same order). */ + public static VoxelShape[] faces(VoxelShape[] shapes) { + if (shapes == null) { + return null; + } + for (VoxelShape s : shapes) { + if (s == null) { + return shapes; + } + } + // VoxelShape doesn't override equals/hashCode, so this list compares the shapes by identity. + List key = List.of(shapes); + synchronized (LOCK) { + VoxelShape[] known = faces.putIfAbsent(key, shapes); + if (known != null && known != shapes) { + MemoryBoost.stateTablesShared++; + return known; + } + } + return shapes; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/BitSetDiscreteVoxelShapeAccessor.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/BitSetDiscreteVoxelShapeAccessor.java new file mode 100644 index 0000000..d14041f --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/BitSetDiscreteVoxelShapeAccessor.java @@ -0,0 +1,15 @@ +package it.cosimo.vesuvio.memory.mixin; + +import java.util.BitSet; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import net.minecraft.world.phys.shapes.BitSetDiscreteVoxelShape; + +/** Read access to a voxel grid's bits, for comparing shapes structurally (ShapeDedup). */ +@Mixin(BitSetDiscreteVoxelShape.class) +public interface BitSetDiscreteVoxelShapeAccessor { + @Accessor("storage") + BitSet vesuvio$getStorage(); +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/BlockShapeTableMixin.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/BlockShapeTableMixin.java new file mode 100644 index 0000000..4f1b1b8 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/BlockShapeTableMixin.java @@ -0,0 +1,28 @@ +package it.cosimo.vesuvio.memory.mixin; + +import java.util.function.Function; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +import it.cosimo.vesuvio.memory.ShapeDedup; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Setting memoryShareBlockShapes: Block.getShapeForEachState (both versions) is where walls, fences, panes, fire, + * redstone wire, vines, buttons, levers... build a shape per state when the block is made; their results go + * through ShapeDedup.intern so equal shapes are one object. + */ +@Mixin(Block.class) +abstract class BlockShapeTableMixin { + @ModifyVariable(method = { + "getShapeForEachState(Ljava/util/function/Function;)Ljava/util/function/Function;", + "getShapeForEachState(Ljava/util/function/Function;[Lnet/minecraft/world/level/block/state/properties/Property;)Ljava/util/function/Function;"}, + at = @At("HEAD"), argsOnly = true, require = 2) + private Function vesuvio$internResults(Function shapeCalculator) { + return ShapeDedup.interning(shapeCalculator); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/BlockStateFaceTableMixin.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/BlockStateFaceTableMixin.java new file mode 100644 index 0000000..534f451 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/BlockStateFaceTableMixin.java @@ -0,0 +1,27 @@ +package it.cosimo.vesuvio.memory.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.memory.StateTableDedup; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Setting memoryShareStateTables: the per-face occlusion shape table BlockStateBase.initCache builds for partly + * occluding states (stairs, slabs, walls...) is shared between states whose six face shapes are the same objects. + * Vanilla already shares the table for empty and full blocks. + */ +@Mixin(BlockBehaviour.BlockStateBase.class) +abstract class BlockStateFaceTableMixin { + @Shadow + private VoxelShape[] occlusionShapesByFace; + + @Inject(method = "initCache", at = @At("RETURN")) + private void vesuvio$shareFaces(CallbackInfo ci) { + this.occlusionShapesByFace = StateTableDedup.faces(this.occlusionShapesByFace); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/BlockStateOcclusionShapeMixin.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/BlockStateOcclusionShapeMixin.java new file mode 100644 index 0000000..76172f2 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/BlockStateOcclusionShapeMixin.java @@ -0,0 +1,23 @@ +package it.cosimo.vesuvio.memory.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; + +import it.cosimo.vesuvio.memory.ShapeDedup; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Setting memoryShareBlockShapes: the occlusion shape each block state caches in initCache is interned before + * its six face shapes are taken, so equal states also share those face slices (VoxelShape caches them). + */ +@Mixin(BlockBehaviour.BlockStateBase.class) +abstract class BlockStateOcclusionShapeMixin { + @ModifyExpressionValue(method = "initCache", at = @At(value = "INVOKE", + target = "Lnet/minecraft/world/level/block/Block;getOcclusionShape(Lnet/minecraft/world/level/block/state/BlockState;)Lnet/minecraft/world/phys/shapes/VoxelShape;")) + private VoxelShape vesuvio$internOcclusion(VoxelShape shape) { + return ShapeDedup.intern(shape); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/CrossCollisionBlockMixin.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/CrossCollisionBlockMixin.java new file mode 100644 index 0000000..43effad --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/CrossCollisionBlockMixin.java @@ -0,0 +1,30 @@ +package it.cosimo.vesuvio.memory.mixin; + +import java.util.List; +import java.util.function.Function; + +import org.spongepowered.asm.mixin.Mixin; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; + +import it.cosimo.vesuvio.memory.ShapeDedup; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.CrossCollisionBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Setting memoryShareBlockShapes: CrossCollisionBlock.makeShapes (fences, glass panes, iron/copper bars) gives the + * same shapes for every block of one class built with the same numbers, so later blocks reuse the first one's + * (ShapeDedup.sharedTable, which also checks the class is vanilla's). + */ +@Mixin(CrossCollisionBlock.class) +abstract class CrossCollisionBlockMixin { + @WrapMethod(method = "makeShapes(FFFFF)Ljava/util/function/Function;") + private Function vesuvio$shareShapes(float postWidth, float postHeight, float wallWidth, + float wallBottom, float wallTop, Operation> original) { + return ShapeDedup.sharedTable((Block) (Object) this, List.of(postWidth, postHeight, wallWidth, wallBottom, wallTop), + () -> original.call(postWidth, postHeight, wallWidth, wallBottom, wallTop)); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/IdentifierMixin.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/IdentifierMixin.java new file mode 100644 index 0000000..124b704 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/IdentifierMixin.java @@ -0,0 +1,33 @@ +package it.cosimo.vesuvio.memory.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import net.minecraft.resources.Identifier; + +/** + * Setting memoryShareIdNamespaces (idea from FerriteCore/ModernFix identifier deduplication; own code). Ids parsed + * from text ("minecraft:stone" in models, recipes, tags, network packets) each keep their own copy of the string + * "minecraft"; this makes them use the one constant instead. Strings are compared with equals everywhere, so + * nothing changes but memory (~56 bytes per such id). + */ +@Mixin(Identifier.class) +abstract class IdentifierMixin { + @Shadow + @Final + @Mutable + private String namespace; + + @Inject(method = "", at = @At("RETURN")) + private void vesuvio$shareNamespace(String namespace, String path, CallbackInfo ci) { + String ns = this.namespace; + if (ns != Identifier.DEFAULT_NAMESPACE && Identifier.DEFAULT_NAMESPACE.equals(ns)) { + this.namespace = Identifier.DEFAULT_NAMESPACE; + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/PalettedContainerMixin.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/PalettedContainerMixin.java new file mode 100644 index 0000000..b053a2e --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/PalettedContainerMixin.java @@ -0,0 +1,46 @@ +package it.cosimo.vesuvio.memory.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import it.cosimo.vesuvio.memory.LightLock; +import net.minecraft.util.ThreadingDetector; +import net.minecraft.world.level.chunk.PalettedContainer; + +/** + * Setting memoryLightThreadChecks: chunk sections' block and biome containers skip their ThreadingDetector + * (Semaphore + ReentrantLock, ~128 bytes each, and new ones for every copy made to build a section mesh or save a + * chunk) and use {@link LightLock} instead. Only applied when the setting is on at start-up. + */ +@Mixin(PalettedContainer.class) +abstract class PalettedContainerMixin { + /** null, the Thread inside acquire()..release(), or the crash report to throw (see LightLock). */ + @Unique + private volatile Object vesuvio$holder; + + /** The field initializer, inlined in the four constructors that don't delegate to this(...). */ + @Redirect(method = "*", at = @At(value = "NEW", target = "net/minecraft/util/ThreadingDetector"), require = 4) + private ThreadingDetector vesuvio$noDetector(String name) { + return LightLock.AVAILABLE ? LightLock.MARKER : new ThreadingDetector(name); + } + + @Redirect(method = "acquire", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/ThreadingDetector;checkAndLock()V")) + private void vesuvio$lock(ThreadingDetector detector) { + if (detector != LightLock.MARKER) { + detector.checkAndLock(); + } else { + LightLock.lock((PalettedContainer) (Object) this); + } + } + + @Redirect(method = "release", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/ThreadingDetector;checkAndUnlock()V")) + private void vesuvio$unlock(ThreadingDetector detector) { + if (detector != LightLock.MARKER) { + detector.checkAndUnlock(); + } else { + LightLock.unlock((PalettedContainer) (Object) this); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/StateCacheShapeMixin.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/StateCacheShapeMixin.java new file mode 100644 index 0000000..7519099 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/StateCacheShapeMixin.java @@ -0,0 +1,22 @@ +package it.cosimo.vesuvio.memory.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; + +import it.cosimo.vesuvio.memory.ShapeDedup; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Setting memoryShareBlockShapes: the collision shape each block state caches at start-up + * (BlockBehaviour.BlockStateBase.Cache) is interned too, for blocks that build it fresh on every call. + */ +@Mixin(targets = "net.minecraft.world.level.block.state.BlockBehaviour$BlockStateBase$Cache") +abstract class StateCacheShapeMixin { + @ModifyExpressionValue(method = "", at = @At(value = "INVOKE", + target = "Lnet/minecraft/world/level/block/Block;getCollisionShape(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/world/level/BlockGetter;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/phys/shapes/CollisionContext;)Lnet/minecraft/world/phys/shapes/VoxelShape;")) + private VoxelShape vesuvio$internCollision(VoxelShape shape) { + return ShapeDedup.intern(shape); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/StateCacheSupportMixin.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/StateCacheSupportMixin.java new file mode 100644 index 0000000..fab36c2 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/StateCacheSupportMixin.java @@ -0,0 +1,29 @@ +package it.cosimo.vesuvio.memory.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.memory.StateTableDedup; +import net.minecraft.world.level.block.state.BlockState; + +/** + * Setting memoryShareStateTables: the face-support table of BlockBehaviour.BlockStateBase.Cache (built once per + * state at start-up, read-only afterwards) is shared between states with equal tables. + */ +@Mixin(targets = "net.minecraft.world.level.block.state.BlockBehaviour$BlockStateBase$Cache") +abstract class StateCacheSupportMixin { + @Shadow + @Final + @Mutable + private boolean[] faceSturdy; + + @Inject(method = "", at = @At("RETURN")) + private void vesuvio$shareSupport(BlockState state, CallbackInfo ci) { + this.faceSturdy = StateTableDedup.support(this.faceSturdy); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/VoxelShapeAccessor.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/VoxelShapeAccessor.java new file mode 100644 index 0000000..d38916f --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/VoxelShapeAccessor.java @@ -0,0 +1,14 @@ +package it.cosimo.vesuvio.memory.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import net.minecraft.world.phys.shapes.DiscreteVoxelShape; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** Read access to a shape's voxel grid, for comparing shapes structurally (ShapeDedup). */ +@Mixin(VoxelShape.class) +public interface VoxelShapeAccessor { + @Accessor("shape") + DiscreteVoxelShape vesuvio$getShape(); +} diff --git a/src/main/java/it/cosimo/vesuvio/memory/mixin/WallBlockMixin.java b/src/main/java/it/cosimo/vesuvio/memory/mixin/WallBlockMixin.java new file mode 100644 index 0000000..6d89ea8 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/memory/mixin/WallBlockMixin.java @@ -0,0 +1,30 @@ +package it.cosimo.vesuvio.memory.mixin; + +import java.util.List; +import java.util.function.Function; + +import org.spongepowered.asm.mixin.Mixin; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; + +import it.cosimo.vesuvio.memory.ShapeDedup; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.WallBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Setting memoryShareBlockShapes: WallBlock.makeShapes(postHeight, wallTop) gives the same shapes for every wall + * (they depend only on the two numbers and the state's properties), so the second wall onwards reuses the first + * wall's shapes instead of running 324 shape unions again (ShapeDedup.sharedTable). + */ +@Mixin(WallBlock.class) +abstract class WallBlockMixin { + @WrapMethod(method = "makeShapes(FF)Ljava/util/function/Function;") + private Function vesuvio$shareShapes(float postHeight, float wallTop, + Operation> original) { + return ShapeDedup.sharedTable((Block) (Object) this, List.of(postHeight, wallTop), + () -> original.call(postHeight, wallTop)); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/mixin/LevelChangeMixin.java b/src/main/java/it/cosimo/vesuvio/mixin/LevelChangeMixin.java new file mode 100644 index 0000000..a5bd824 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/mixin/LevelChangeMixin.java @@ -0,0 +1,22 @@ +package it.cosimo.vesuvio.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.Vesuvio; +import net.minecraft.client.renderer.extract.LevelExtractor; + +/** + * LevelExtractor.setLevel: the client joins a world, changes dimension, or leaves (null). Vesuvio's per-world render + * state is let go here, so nothing keeps the old world's sections, meshes or render targets alive. Render thread, + * outside any render pass. Only releases references; vanilla's call runs unchanged. + */ +@Mixin(LevelExtractor.class) +public abstract class LevelChangeMixin { + @Inject(method = "setLevel", at = @At("HEAD")) + private void vesuvio$levelChanged(CallbackInfo ci) { + Vesuvio.levelChanged(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/models/ModelFastPath.java b/src/main/java/it/cosimo/vesuvio/models/ModelFastPath.java new file mode 100644 index 0000000..28bd4c5 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/models/ModelFastPath.java @@ -0,0 +1,212 @@ +package it.cosimo.vesuvio.models; + +import org.joml.Matrix4f; +import org.joml.Vector3f; + +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; + +import it.cosimo.vesuvio.VesuvioConfig; +import net.minecraft.client.model.geom.ModelPart; + +/** + * Entity model fast path (idea from Sodium's entity renderer; Vesuvio's own code). Vanilla's + * {@code ModelPart.Cube.compile} transforms every vertex of every face with the pose matrix: 24 transforms for a + * box, although a box has only 8 corners, each shared by 3 faces. Here each distinct corner is transformed once and + * the 24 vertices are written from those. + * + *

The bytes written are the same as vanilla's, bit for bit: + *

    + *
  • Each corner goes through the same {@code Matrix4f.transformPosition(x, y, z, dest)} call vanilla makes for each + * vertex, with the same matrix and the same inputs ({@code Vertex.worldX()} etc.). Java float arithmetic is exact + * and deterministic (strict IEEE since Java 17), so a corner transformed once gives the very floats vanilla gets each + * time it transforms that corner again. Corners are told apart by the bits of their inputs, never by "close + * enough".
  • + *
  • Normals are transformed exactly as vanilla does, once per face, with {@code PoseStack.Pose.transformNormal} + * (normal matrix, normalised only when the pose is not trusted; mirrored and negative-scale parts are handled by the + * pose itself, as in vanilla: compile flips nothing).
  • + *
  • The vertices go out in vanilla's order through the same 11-argument {@code VertexConsumer.addVertex}, so + * BufferBuilder keeps its own fast path and every wrapping consumer (decals, outlines, glint) sees the same calls.
  • + *
+ * The corner layout of a cube is worked out once and kept on the cube; before each use it is checked against the + * cube's current faces and vertices (by identity), so a mod that swaps them gets a fresh layout, never a stale one. + */ +public final class ModelFastPath { + public static final VesuvioConfig.Toggle FAST_MODELS = VesuvioConfig.toggle("entities", "fastEntityModels", true, false); + + /** Cubes written by the fast path since the last frame ended, and last frame (counter). */ + static int cubes; + public static int cubesLastFrame; + + /** + * Corner positions transformed this call (3 floats each). Models are drawn on the render thread, which uses + * {@link #renderScratch} without a thread-local lookup per cube; any other thread gets its own array. + */ + private static final ThreadLocal SCRATCH = ThreadLocal.withInitial(() -> new float[24]); + private static float[] renderScratch = new float[24]; + + private ModelFastPath() { + } + + /** End of a frame (command encoder submit): publishes the counter. */ + public static void endFrame() { + cubesLastFrame = cubes; + cubes = 0; + } + + /** + * How one cube's vertices map onto its distinct corners. Immutable (final fields: safe to publish through a plain + * field). Built from the cube's faces as they are; {@link #matches} tells whether they still are. + */ + public static final class Layout { + /** The faces as they were (the cube's array elements, in order). */ + final ModelPart.Polygon[] polygons; + /** Every vertex of every face, in vanilla's emission order. */ + final ModelPart.Vertex[] vertices; + /** Index into {@link #vertices} where each face's vertices end. */ + final int[] faceEnds; + /** Distinct corners: worldX, worldY, worldZ for each. */ + final float[] corners; + /** For each vertex, its corner times 3 (offset into {@link #corners} and the transformed scratch). */ + final int[] cornerOffset; + /** For each vertex, its u and v (copied: Vertex is an immutable record, and {@link #matches} checks identity). */ + final float[] uvs; + /** False if no two vertices share a corner (nothing to save): vanilla's own code runs. */ + final boolean worthIt; + + private Layout(ModelPart.Polygon[] polygons, ModelPart.Vertex[] vertices, int[] faceEnds, float[] corners, + int[] cornerOffset, float[] uvs, boolean worthIt) { + this.polygons = polygons; + this.vertices = vertices; + this.faceEnds = faceEnds; + this.corners = corners; + this.cornerOffset = cornerOffset; + this.uvs = uvs; + this.worthIt = worthIt; + } + + /** The layout of these faces as they are now. */ + public static Layout of(ModelPart.Polygon[] faces) { + ModelPart.Polygon[] polygons = faces.clone(); + int total = 0; + for (ModelPart.Polygon polygon : polygons) { + total += polygon.vertices().length; + } + ModelPart.Vertex[] vertices = new ModelPart.Vertex[total]; + int[] faceEnds = new int[polygons.length]; + int[] cornerOffset = new int[total]; + float[] corners = new float[total * 3]; + float[] uvs = new float[total * 2]; + int cornerCount = 0; + int k = 0; + for (int p = 0; p < polygons.length; p++) { + for (ModelPart.Vertex vertex : polygons[p].vertices()) { + float x = vertex.worldX(); + float y = vertex.worldY(); + float z = vertex.worldZ(); + int found = -1; + for (int c = 0; c < cornerCount; c++) { + if (Float.floatToRawIntBits(corners[c * 3]) == Float.floatToRawIntBits(x) + && Float.floatToRawIntBits(corners[c * 3 + 1]) == Float.floatToRawIntBits(y) + && Float.floatToRawIntBits(corners[c * 3 + 2]) == Float.floatToRawIntBits(z)) { + found = c; + break; + } + } + if (found < 0) { + found = cornerCount++; + corners[found * 3] = x; + corners[found * 3 + 1] = y; + corners[found * 3 + 2] = z; + } + vertices[k] = vertex; + cornerOffset[k] = found * 3; + uvs[k * 2] = vertex.u(); + uvs[k * 2 + 1] = vertex.v(); + k++; + } + faceEnds[p] = k; + } + float[] trimmed = new float[cornerCount * 3]; + System.arraycopy(corners, 0, trimmed, 0, trimmed.length); + return new Layout(polygons, vertices, faceEnds, trimmed, cornerOffset, uvs, cornerCount < total); + } + + /** True if the cube's faces and their vertices are still exactly the ones this layout was made from. */ + public boolean matches(ModelPart.Polygon[] faces) { + ModelPart.Polygon[] polygons = this.polygons; + if (faces.length != polygons.length) { + return false; + } + ModelPart.Vertex[] vertices = this.vertices; + int k = 0; + for (int p = 0; p < polygons.length; p++) { + ModelPart.Polygon polygon = faces[p]; + if (polygon != polygons[p]) { + return false; + } + // Same polygon (a record) means the same vertex array; its elements could still have been replaced. + ModelPart.Vertex[] faceVertices = polygon.vertices(); + if (k + faceVertices.length != this.faceEnds[p]) { + return false; + } + for (ModelPart.Vertex vertex : faceVertices) { + if (vertex != vertices[k++]) { + return false; + } + } + } + return true; + } + } + + /** + * Writes the cube the way {@code ModelPart.Cube.compile(pose, builder, lightCoords, overlayCoords, color)} does, + * from {@code layout} (which must {@link Layout#matches match} the cube). False, with nothing written, if the + * layout has nothing to save: the caller then runs vanilla's code. {@code scratchVector} is vanilla's own scratch + * vector of that call (its contents do not matter: every use overwrites it). + */ + public static boolean compile(Layout layout, PoseStack.Pose pose, VertexConsumer builder, int lightCoords, + int overlayCoords, int color, Vector3f scratchVector) { + if (!layout.worthIt) { + return false; + } + Matrix4f matrix = pose.pose(); + float[] corners = layout.corners; + boolean renderThread = RenderSystem.isOnRenderThread(); + float[] transformed = renderThread ? renderScratch : SCRATCH.get(); + if (transformed.length < corners.length) { + transformed = new float[corners.length]; + if (renderThread) { + renderScratch = transformed; + } else { + SCRATCH.set(transformed); + } + } + for (int c = 0; c < corners.length; c += 3) { + Vector3f pos = matrix.transformPosition(corners[c], corners[c + 1], corners[c + 2], scratchVector); + transformed[c] = pos.x(); + transformed[c + 1] = pos.y(); + transformed[c + 2] = pos.z(); + } + ModelPart.Polygon[] polygons = layout.polygons; + int[] faceEnds = layout.faceEnds; + float[] uvs = layout.uvs; + int[] cornerOffset = layout.cornerOffset; + int k = 0; + for (int p = 0; p < polygons.length; p++) { + Vector3f normal = pose.transformNormal(polygons[p].normal(), scratchVector); + float nx = normal.x(); + float ny = normal.y(); + float nz = normal.z(); + for (int end = faceEnds[p]; k < end; k++) { + int c = cornerOffset[k]; + builder.addVertex(transformed[c], transformed[c + 1], transformed[c + 2], color, uvs[k * 2], uvs[k * 2 + 1], + overlayCoords, lightCoords, nx, ny, nz); + } + } + cubes++; + return true; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/models/Models.java b/src/main/java/it/cosimo/vesuvio/models/Models.java new file mode 100644 index 0000000..529a302 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/models/Models.java @@ -0,0 +1,22 @@ +package it.cosimo.vesuvio.models; + +import it.cosimo.vesuvio.ui.VesuvioStats; + +/** + * Less CPU work per frame to write entity models and sign text, with the same vertices as vanilla (Entities page): + * {@link ModelFastPath} transforms each model cube's corners once, {@link SignTextCache} keeps each sign line's glyph + * layout instead of redoing it every frame. + */ +public final class Models { + private Models() { + } + + /** Creates the settings, in display order, and registers the counters. */ + public static void init() { + VesuvioStats.register("model cubes fast", () -> ModelFastPath.cubesLastFrame); + VesuvioStats.register("sign text cache hits", () -> SignTextCache.hitsLastFrame); + // Touch both classes so their settings exist now, in this order. + ModelFastPath.FAST_MODELS.get(); + SignTextCache.SIGN_TEXT_CACHE.get(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/models/SignTextCache.java b/src/main/java/it/cosimo/vesuvio/models/SignTextCache.java new file mode 100644 index 0000000..b11e346 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/models/SignTextCache.java @@ -0,0 +1,334 @@ +package it.cosimo.vesuvio.models; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.mojang.blaze3d.systems.RenderSystem; + +import it.cosimo.vesuvio.VesuvioConfig; +import net.minecraft.client.gui.Font; +import net.minecraft.network.chat.FontDescription; +import net.minecraft.network.chat.Style; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.FormattedCharSink; + +/** + * Cached sign text (idea from ImmediatelyFast; Vesuvio's own code). Every frame vanilla measures each sign line + * ({@code Font.width}) and lays it out again ({@code Font.prepareText}: a glyph lookup and a new glyph object per + * letter, eight times over for an outlined glowing line) before writing its vertices. The layout does not depend on + * where the sign is: it is a list of glyphs at text-space positions, and the pose is applied only when the vertices + * are written ({@code TextRenderable.render(pose, ...)}). So the layout of each line is kept and replayed with the + * current pose: same glyphs, same vertices, same order. + * + *

What makes a kept layout exact: + *

    + *
  • The line itself. Vanilla keeps each sign's lines in its {@code SignText} (immutable; a changed sign is a new + * one) as finished character sequences. Here each such line is wrapped once ({@link Line}, which reads exactly like + * the line it wraps), and the wrapper carries the cache, so a cached layout can only ever be used for the very + * sequence it was made from.
  • + *
  • Everything else the layout depends on is checked on every use: the font object, position, colour, shadow, + * background and outline colour, and a font generation that goes up whenever the game rebuilds its glyphs (resource + * reload, font options such as Force Unicode, closing a font set) or the world changes.
  • + *
  • Lines that would not lay out the same way twice are never cached: obfuscated (§k) text picks random glyphs every + * frame, and sprite or player-head fonts can change their glyphs without a reload (a skin finishing its download). + * Those lines run vanilla's code every frame.
  • + *
+ * At most {@link #MAX_LINES} lines keep a layout; the least recently drawn give theirs up first. + */ +public final class SignTextCache { + public static final VesuvioConfig.Toggle SIGN_TEXT_CACHE = VesuvioConfig.toggle("entities", "signTextCache", true, false); + + /** Most sign lines that keep a layout at once (about 1 KB each, 8 KB for an outlined glowing line). */ + static final int MAX_LINES = 4096; + + /** Layouts served from the cache since the last frame ended, and last frame (counter). */ + private static int hits; + public static int hitsLastFrame; + + /** Goes up whenever glyphs may have changed; any thread. */ + private static volatile int fontGeneration; + + // ---- Render thread only ---- + /** The generation the cache was last emptied for. */ + private static int clearedGeneration; + /** Lines holding a layout, most recently used first. */ + private static Line head; + private static Line tail; + private static int size; + + private SignTextCache() { + } + + /** End of a frame (command encoder submit): publishes the counter. */ + public static void endFrame() { + hitsLastFrame = hits; + hits = 0; + } + + /** Glyphs may have changed (font set rebuilt or closed, world changed): every kept layout is dropped. */ + public static void invalidate() { + fontGeneration++; + if (RenderSystem.isOnRenderThread()) { + // Let go of the kept layouts now (they point at the old glyphs), not at the next sign drawn. + generation(); + } + } + + /** A sign's lines as wrapped for one {@code getRenderMessages} array (kept on the SignText). */ + public static final class Lines { + final FormattedCharSequence[] source; + final FormattedCharSequence[] wrapped; + + Lines(FormattedCharSequence[] source) { + this.source = source; + this.wrapped = new FormattedCharSequence[source.length]; + for (int i = 0; i < source.length; i++) { + FormattedCharSequence line = source[i]; + // An empty line costs vanilla nothing (and EMPTY is one object shared by every sign). + this.wrapped[i] = line == FormattedCharSequence.EMPTY || line == null ? line : new Line(line); + } + } + + boolean wraps(FormattedCharSequence[] messages) { + if (messages != this.source || messages.length != this.wrapped.length) { + return false; + } + for (int i = 0; i < messages.length; i++) { + FormattedCharSequence w = this.wrapped[i]; + FormattedCharSequence original = w instanceof Line line ? line.line : w; + if (original != messages[i]) { + return false; + } + } + return true; + } + } + + /** Implemented on SignText by a mixin: where a sign's wrapped lines are kept. */ + public interface Holder { + Lines vesuvio$signLines(); + + void vesuvio$setSignLines(Lines lines); + } + + /** + * One sign line: reads exactly like the vanilla line it wraps (every visitor sees the same characters and styles), + * and carries that line's cached width and layouts. + */ + public static final class Line implements FormattedCharSequence { + final FormattedCharSequence line; + /** 0 not checked yet, 1 can be cached, 2 never (obfuscated, or a font that is not a plain resource font). */ + private byte cacheable; + + private Font widthFont; + private int widthGeneration; + private int width; + + private Font textFont; + private int textGeneration; + private int textX; + private int textY; + private int textColor; + private boolean textShadow; + private boolean textIncludeEmpty; + private int textBackground; + private Font.PreparedText text; + + private Font outlineFont; + private int outlineGeneration; + private int outlineX; + private int outlineY; + private int outlineColor; + private Font.PreparedText outline; + + private Line prev; + private Line next; + private boolean linked; + + Line(FormattedCharSequence line) { + this.line = line; + } + + @Override + public boolean accept(FormattedCharSink output) { + return this.line.accept(output); + } + + boolean cacheable() { + if (this.cacheable == 0) { + boolean[] ok = { true }; + this.line.accept((position, style, codepoint) -> { + if (!stable(style)) { + ok[0] = false; + return false; + } + return true; + }); + this.cacheable = ok[0] ? (byte) 1 : (byte) 2; + } + return this.cacheable == 1; + } + + private static boolean stable(Style style) { + return !style.isObfuscated() && style.getFont() instanceof FontDescription.Resource; + } + + void drop() { + this.text = null; + this.textFont = null; + this.outline = null; + this.outlineFont = null; + } + } + + /** AbstractSignRenderer.submitSignText: the sign's lines, wrapped so the text renderer can find their cache. */ + public static FormattedCharSequence[] wrap(Object signText, FormattedCharSequence[] messages) { + if (messages == null || !SIGN_TEXT_CACHE.on() || !RenderSystem.isOnRenderThread()) { + return messages; + } + Holder holder = (Holder) signText; + Lines lines = holder.vesuvio$signLines(); + if (lines == null || !lines.wraps(messages)) { + lines = new Lines(messages); + holder.vesuvio$setSignLines(lines); + } + return lines.wrapped; + } + + /** AbstractSignRenderer.submitSignText, {@code font.width(line)}. */ + public static int width(Font font, FormattedCharSequence text, Operation original) { + if (!(text instanceof Line line) || !usable(line)) { + return original.call(font, text); + } + int generation = generation(); + if (line.widthFont == font && line.widthGeneration == generation) { + return line.width; + } + int width = original.call(font, text); + line.widthFont = font; + line.widthGeneration = generation; + line.width = width; + return width; + } + + /** TextFeatureRenderer.renderText, {@code font.prepareText(...)}. */ + public static Font.PreparedText prepareText(Font font, FormattedCharSequence text, float x, float y, int color, + boolean drawShadow, boolean includeEmpty, int backgroundColor, Operation original) { + if (!(text instanceof Line line) || !usable(line)) { + return original.call(font, text, x, y, color, drawShadow, includeEmpty, backgroundColor); + } + int generation = generation(); + int xBits = Float.floatToRawIntBits(x); + int yBits = Float.floatToRawIntBits(y); + if (line.text != null && line.textFont == font && line.textGeneration == generation && line.textX == xBits + && line.textY == yBits && line.textColor == color && line.textShadow == drawShadow + && line.textIncludeEmpty == includeEmpty && line.textBackground == backgroundColor) { + hits++; + touch(line); + return line.text; + } + Font.PreparedText prepared = original.call(font, text, x, y, color, drawShadow, includeEmpty, backgroundColor); + line.textFont = font; + line.textGeneration = generation; + line.textX = xBits; + line.textY = yBits; + line.textColor = color; + line.textShadow = drawShadow; + line.textIncludeEmpty = includeEmpty; + line.textBackground = backgroundColor; + line.text = prepared; + touch(line); + return prepared; + } + + /** TextFeatureRenderer.renderText, {@code font.prepare8xTextOutline(...)} (glowing text with an outline). */ + public static Font.PreparedText prepareOutline(Font font, FormattedCharSequence text, float x, float y, + int outlineColor, Operation original) { + if (!(text instanceof Line line) || !usable(line)) { + return original.call(font, text, x, y, outlineColor); + } + int generation = generation(); + int xBits = Float.floatToRawIntBits(x); + int yBits = Float.floatToRawIntBits(y); + if (line.outline != null && line.outlineFont == font && line.outlineGeneration == generation + && line.outlineX == xBits && line.outlineY == yBits && line.outlineColor == outlineColor) { + hits++; + touch(line); + return line.outline; + } + Font.PreparedText prepared = original.call(font, text, x, y, outlineColor); + line.outlineFont = font; + line.outlineGeneration = generation; + line.outlineX = xBits; + line.outlineY = yBits; + line.outlineColor = outlineColor; + line.outline = prepared; + touch(line); + return prepared; + } + + private static boolean usable(Line line) { + return SIGN_TEXT_CACHE.on() && RenderSystem.isOnRenderThread() && line.cacheable(); + } + + /** The current font generation; empties the cache first if it went up since the last time. */ + private static int generation() { + int generation = fontGeneration; + if (generation != clearedGeneration) { + for (Line line = head; line != null;) { + Line next = line.next; + line.drop(); + line.prev = null; + line.next = null; + line.linked = false; + line = next; + } + head = null; + tail = null; + size = 0; + clearedGeneration = generation; + } + return generation; + } + + /** Moves a line to the front of the LRU list (adding it if new) and lets the oldest go past the bound. */ + private static void touch(Line line) { + if (line.linked) { + if (line == head) { + return; + } + unlink(line); + } + line.next = head; + line.prev = null; + if (head != null) { + head.prev = line; + } + head = line; + if (tail == null) { + tail = line; + } + line.linked = true; + size++; + while (size > MAX_LINES && tail != null) { + Line oldest = tail; + unlink(oldest); + oldest.drop(); + } + } + + private static void unlink(Line line) { + if (line.prev != null) { + line.prev.next = line.next; + } else { + head = line.next; + } + if (line.next != null) { + line.next.prev = line.prev; + } else { + tail = line.prev; + } + line.prev = null; + line.next = null; + line.linked = false; + size--; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/models/mixin/FontSetGenerationMixin.java b/src/main/java/it/cosimo/vesuvio/models/mixin/FontSetGenerationMixin.java new file mode 100644 index 0000000..5547070 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/models/mixin/FontSetGenerationMixin.java @@ -0,0 +1,26 @@ +package it.cosimo.vesuvio.models.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.models.SignTextCache; +import net.minecraft.client.gui.font.FontSet; + +/** + * Setting signTextCache: a font set throws its glyphs away on a resource reload, on a font option change (Force + * Unicode, Japanese glyph variants) and when it is closed; every kept sign text layout goes with them. + */ +@Mixin(FontSet.class) +abstract class FontSetGenerationMixin { + @Inject(method = "resetTextures", at = @At("HEAD")) + private void vesuvio$glyphsReset(CallbackInfo ci) { + SignTextCache.invalidate(); + } + + @Inject(method = "close", at = @At("HEAD")) + private void vesuvio$fontSetClosed(CallbackInfo ci) { + SignTextCache.invalidate(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/models/mixin/ModelCubeMixin.java b/src/main/java/it/cosimo/vesuvio/models/mixin/ModelCubeMixin.java new file mode 100644 index 0000000..d8c5ac2 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/models/mixin/ModelCubeMixin.java @@ -0,0 +1,59 @@ +package it.cosimo.vesuvio.models.mixin; + +import org.joml.Vector3f; +import org.objectweb.asm.Opcodes; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.sugar.Local; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; + +import it.cosimo.vesuvio.models.ModelFastPath; +import net.minecraft.client.model.geom.ModelPart; + +/** + * Setting fastEntityModels: each cube's 8 corners are transformed once instead of once per face (24 vertices), with + * the same bytes written ({@link ModelFastPath}). The corner layout is kept on the cube and checked before each use. + */ +@Mixin(ModelPart.Cube.class) +abstract class ModelCubeMixin { + @Shadow + @Final + public ModelPart.Polygon[] polygons; + + @Unique + private static final ModelPart.Polygon[] NO_POLYGONS = new ModelPart.Polygon[0]; + + @Unique + private ModelFastPath.Layout vesuvio$layout; + + /** + * The cube's face list as compile is about to loop over it: when the fast path has written the cube, the loop + * gets no faces and vanilla's compile ends having written nothing more. Unlike a cancellable HEAD inject this + * allocates no CallbackInfo per cube; the fast path reuses vanilla's scratch vector of the call. + */ + @ModifyExpressionValue(method = "compile", at = @At(value = "FIELD", opcode = Opcodes.GETFIELD, + target = "Lnet/minecraft/client/model/geom/ModelPart$Cube;polygons:[Lnet/minecraft/client/model/geom/ModelPart$Polygon;")) + private ModelPart.Polygon[] vesuvio$fastCompile(ModelPart.Polygon[] polygons, + @Local(argsOnly = true) PoseStack.Pose pose, @Local(argsOnly = true) VertexConsumer builder, + @Local(argsOnly = true, ordinal = 0) int lightCoords, @Local(argsOnly = true, ordinal = 1) int overlayCoords, + @Local(argsOnly = true, ordinal = 2) int color, @Local(ordinal = 0) Vector3f scratchVector) { + if (!ModelFastPath.FAST_MODELS.on()) { + return polygons; + } + ModelFastPath.Layout layout = this.vesuvio$layout; + if (layout == null || !layout.matches(this.polygons)) { + layout = ModelFastPath.Layout.of(this.polygons); + this.vesuvio$layout = layout; + } + if (ModelFastPath.compile(layout, pose, builder, lightCoords, overlayCoords, color, scratchVector)) { + return NO_POLYGONS; + } + return polygons; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/models/mixin/ModelsFrameMixin.java b/src/main/java/it/cosimo/vesuvio/models/mixin/ModelsFrameMixin.java new file mode 100644 index 0000000..1ab819d --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/models/mixin/ModelsFrameMixin.java @@ -0,0 +1,21 @@ +package it.cosimo.vesuvio.models.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.renderpearl.backend.vulkan.VulkanCommandEncoder; + +import it.cosimo.vesuvio.models.ModelFastPath; +import it.cosimo.vesuvio.models.SignTextCache; + +/** Settings fastEntityModels and signTextCache: publishes the frame's counters at submit (only counts). */ +@Mixin(VulkanCommandEncoder.class) +abstract class ModelsFrameMixin { + @Inject(method = "submit", at = @At("HEAD")) + private void vesuvio$modelCounters(CallbackInfo ci) { + ModelFastPath.endFrame(); + SignTextCache.endFrame(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/models/mixin/SignTextCacheMixin.java b/src/main/java/it/cosimo/vesuvio/models/mixin/SignTextCacheMixin.java new file mode 100644 index 0000000..eb160f9 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/models/mixin/SignTextCacheMixin.java @@ -0,0 +1,35 @@ +package it.cosimo.vesuvio.models.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.sugar.Local; + +import it.cosimo.vesuvio.models.SignTextCache; +import net.minecraft.client.gui.Font; +import net.minecraft.client.renderer.blockentity.AbstractSignRenderer; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.world.level.block.entity.SignText; + +/** + * Setting signTextCache, sign side: each line goes to the text renderer wrapped in a {@link SignTextCache.Line} (which + * reads exactly like the line), so the text renderer can find the line's kept layout; the line's width is kept too. + */ +@Mixin(AbstractSignRenderer.class) +abstract class SignTextCacheMixin { + @ModifyExpressionValue(method = "submitSignText", at = @At(value = "INVOKE", + target = "Lnet/minecraft/world/level/block/entity/SignText;getRenderMessages(ZLjava/util/function/Function;)[Lnet/minecraft/util/FormattedCharSequence;")) + private FormattedCharSequence[] vesuvio$wrapLines(FormattedCharSequence[] messages, + @Local(argsOnly = true) SignText signText) { + return SignTextCache.wrap(signText, messages); + } + + @WrapOperation(method = "submitSignText", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/gui/Font;width(Lnet/minecraft/util/FormattedCharSequence;)I")) + private int vesuvio$cachedWidth(Font font, FormattedCharSequence text, Operation original) { + return SignTextCache.width(font, text, original); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/models/mixin/SignTextLinesMixin.java b/src/main/java/it/cosimo/vesuvio/models/mixin/SignTextLinesMixin.java new file mode 100644 index 0000000..985142a --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/models/mixin/SignTextLinesMixin.java @@ -0,0 +1,24 @@ +package it.cosimo.vesuvio.models.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import it.cosimo.vesuvio.models.SignTextCache; +import net.minecraft.world.level.block.entity.SignText; + +/** Setting signTextCache: a place on each SignText for its wrapped lines (read and written on the render thread). */ +@Mixin(SignText.class) +abstract class SignTextLinesMixin implements SignTextCache.Holder { + @Unique + private SignTextCache.Lines vesuvio$signLines; + + @Override + public SignTextCache.Lines vesuvio$signLines() { + return this.vesuvio$signLines; + } + + @Override + public void vesuvio$setSignLines(SignTextCache.Lines lines) { + this.vesuvio$signLines = lines; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/models/mixin/TextFeatureCacheMixin.java b/src/main/java/it/cosimo/vesuvio/models/mixin/TextFeatureCacheMixin.java new file mode 100644 index 0000000..eb2e41b --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/models/mixin/TextFeatureCacheMixin.java @@ -0,0 +1,34 @@ +package it.cosimo.vesuvio.models.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; + +import it.cosimo.vesuvio.models.SignTextCache; +import net.minecraft.client.gui.Font; +import net.minecraft.client.renderer.feature.TextFeatureRenderer; +import net.minecraft.util.FormattedCharSequence; + +/** + * Setting signTextCache, text renderer side: a sign line's layout (plain, and the 8-way outline of glowing text) is + * taken from its cache when everything it depends on is unchanged; the vertices are still written every frame with + * the current pose by vanilla's own glyph visitor. Any other text goes straight to vanilla. + */ +@Mixin(TextFeatureRenderer.class) +abstract class TextFeatureCacheMixin { + @WrapOperation(method = "renderText", require = 2, at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/gui/Font;prepareText(Lnet/minecraft/util/FormattedCharSequence;FFIZZI)Lnet/minecraft/client/gui/Font$PreparedText;")) + private static Font.PreparedText vesuvio$cachedText(Font font, FormattedCharSequence text, float x, float y, int color, + boolean drawShadow, boolean includeEmpty, int backgroundColor, Operation original) { + return SignTextCache.prepareText(font, text, x, y, color, drawShadow, includeEmpty, backgroundColor, original); + } + + @WrapOperation(method = "renderText", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/gui/Font;prepare8xTextOutline(Lnet/minecraft/util/FormattedCharSequence;FFI)Lnet/minecraft/client/gui/Font$PreparedText;")) + private static Font.PreparedText vesuvio$cachedOutline(Font font, FormattedCharSequence text, float x, float y, + int outlineColor, Operation original) { + return SignTextCache.prepareOutline(font, text, x, y, outlineColor, original); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/LevelEntityStorage.java b/src/main/java/it/cosimo/vesuvio/ticks/LevelEntityStorage.java new file mode 100644 index 0000000..f412de7 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/LevelEntityStorage.java @@ -0,0 +1,10 @@ +package it.cosimo.vesuvio.ticks; + +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.entity.EntitySectionStorage; + +/** Implemented by Level (mixin). */ +public interface LevelEntityStorage { + /** The section storage behind Level.getEntities(), or null if it is not the vanilla adapter (another mod). */ + EntitySectionStorage vesuvio$entityStorage(); +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/PathTypeMemo.java b/src/main/java/it/cosimo/vesuvio/ticks/PathTypeMemo.java new file mode 100644 index 0000000..84788a9 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/PathTypeMemo.java @@ -0,0 +1,93 @@ +package it.cosimo.vesuvio.ticks; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.jspecify.annotations.Nullable; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.level.pathfinder.PathType; + +/** + * Setting worldPathTypeCache. WalkNodeEvaluator.getPathTypeFromState(level, pos) reads one block state and then + * decides from that state alone: block tags (PathType's tag list, FIRE, CAMPFIRES, FENCES, WALLS), fluid tags of + * the state's fluid, the DoorBlock / FenceGateBlock properties, and isPathfindable(state, LAND), whose vanilla + * versions only use the state (the default one asks the collision shape with EmptyBlockGetter at BlockPos.ZERO). + * The method never uses level or pos again. So its result is a function of the state and the bound tags: we keep + * it on the state, stamped with a tag generation that MappedRegistry.refreshTagsInHolders bumps before and after + * it rebinds any registry's tags (/reload, joining a world). A stale stamp means recompute. + * + *

Misses are computed by the vanilla method itself, given a getter that returns that state for any position, + * so the stored value is exactly what vanilla returns for the state. + */ +public final class PathTypeMemo { + private static final AtomicInteger GENERATION = new AtomicInteger(); + + /** Implemented by BlockStateBase (mixin): the remembered result, or null. */ + public interface Holder { + @Nullable + Entry vesuvio$pathType(); + + void vesuvio$setPathType(Entry entry); + } + + /** Immutable, so a racing reader sees either the old or the new entry, never a mix. */ + public record Entry(int generation, PathType type) { + } + + private PathTypeMemo() { + } + + /** Tags of some registry are being (or were just) rebound. */ + public static void tagsChanged() { + GENERATION.incrementAndGet(); + } + + public static PathType get(BlockGetter level, BlockPos pos, Operation original) { + BlockState state = level.getBlockState(pos); + Holder holder = (Holder) state; + int generation = GENERATION.get(); // read before computing: a rebind during the computation makes it stale + Entry entry = holder.vesuvio$pathType(); + if (entry != null && entry.generation() == generation) { + TickStats.pathTypeHits++; + return entry.type(); + } + TickStats.pathTypeMisses++; + PathType type = original.call(new OneState(state), pos); + holder.vesuvio$setPathType(new Entry(generation, type)); + return type; + } + + /** A getter that answers {@code state} everywhere: the vanilla method then computes the state-only result. */ + private record OneState(BlockState state) implements BlockGetter { + @Override + public @Nullable BlockEntity getBlockEntity(BlockPos pos) { + return null; + } + + @Override + public BlockState getBlockState(BlockPos pos) { + return this.state; + } + + @Override + public FluidState getFluidState(BlockPos pos) { + return this.state.getFluidState(); + } + + @Override + public int getHeight() { + return 384; + } + + @Override + public int getMinY() { + return -64; + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/RareEntities.java b/src/main/java/it/cosimo/vesuvio/ticks/RareEntities.java new file mode 100644 index 0000000..d7b9371 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/RareEntities.java @@ -0,0 +1,136 @@ +package it.cosimo.vesuvio.ticks; + +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; + +import com.google.common.collect.Lists; + +import it.cosimo.vesuvio.VesuvioConfig; +import net.minecraft.util.AbortableIterationConsumer; +import net.minecraft.util.Continuation; +import net.minecraft.util.profiling.Profiler; +import net.minecraft.world.Container; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.boss.enderdragon.EnderDragonPart; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.entity.EntityAccess; +import net.minecraft.world.level.entity.EntitySection; +import net.minecraft.world.level.entity.EntitySectionStorage; +import net.minecraft.world.phys.AABB; + +/** + * Setting worldRareEntityIndex. Two box lookups run for many entities every tick but can only ever return a few + * kinds of entity: + *

    + *
  • entity collisions (Level.getEntityCollisions: every moving entity in Entity.collide, every item entity's + * noCollision check): with the vanilla predicate an entity passes only if its {@code canBeCollidedWith} says so, + * and Entity's own version is {@code return false}. Only classes that override it can pass (boats, minecarts, + * shulkers, happy ghasts in vanilla).
  • + *
  • hopper container entities (HopperBlockEntity.getEntityContainer, every idle hopper every tick): the + * predicate starts with {@code entity instanceof Container}.
  • + *
+ * Each entity section keeps, next to its own list, the sub-list of entities of those classes, updated in the + * same add/remove calls in the same order. A lookup then walks the same sections (same storage method) and in + * each only the sub-list, applying the unchanged vanilla tests: the result list is the same list in the same + * order (the dropped entities could never pass), so the random index the hopper picks is the same too. + */ +public final class RareEntities { + public static final int COLLIDABLE = 0; + public static final int CONTAINER = 1; + public static final int KINDS = 2; + + /** Whether the sections fill their lists in this session (decided once, at start-up: restart setting). */ + public static final boolean MAINTAIN = WorldTicks.RARE_ENTITY_INDEX.get() && VesuvioConfig.ENABLED.get(); + + /** Bit k set: instances of the class go in list k. */ + private static final ClassValue KIND = new ClassValue<>() { + @Override + protected Integer computeValue(Class type) { + if (!Entity.class.isAssignableFrom(type)) { + return 0; // not an Entity: never in a Level's entity storage, never looked up here + } + int bits = 0; + if (overrides(type, "canBeCollidedWith")) { + bits |= 1 << COLLIDABLE; + } + if (Container.class.isAssignableFrom(type)) { + bits |= 1 << CONTAINER; + } + return bits; + } + }; + + /** Whether the class has its own canCollideWith (boats and minecarts also collide with pushable entities). */ + private static final ClassValue OWN_CAN_COLLIDE_WITH = new ClassValue<>() { + @Override + protected Boolean computeValue(Class type) { + return overrides(type, "canCollideWith"); + } + }; + + private RareEntities() { + } + + static void init() { + // Class initialisation decides MAINTAIN. + } + + /** The declaring class of the public method {@code name(Entity)} is not Entity. Unsure means yes (safe). */ + private static boolean overrides(Class type, String name) { + try { + return type.getMethod(name, Entity.class).getDeclaringClass() != Entity.class; + } catch (Throwable e) { + return true; + } + } + + /** Bit set of the lists an entity belongs to (a class-only property, so it never changes for an entity). */ + public static int kinds(Object entity) { + return KIND.get(entity.getClass()); + } + + /** The index can be used right now. */ + public static boolean active() { + return MAINTAIN && WorldTicks.RARE_ENTITY_INDEX.on(); + } + + /** Whether getEntityCollisions(source, ...) uses the plain vanilla predicate (Entity.canCollideWith). */ + public static boolean plainCollider(Entity source) { + return source == null || !OWN_CAN_COLLIDE_WITH.get(source.getClass()); + } + + /** + * Level.getEntities(except, bb, selector) restricted to the entities of one kind. Callers guarantee that an + * entity outside the kind can never pass {@code selector}. Mirrors vanilla step by step: the profiler counter, + * sections through the storage's own walk, per section the entities whose box intersects bb (vanilla tests + * that first), then {@code entity != except && selector.test(entity)}, then the dragon parts loop. + */ + public static List getEntities(Level level, EntitySectionStorage storage, Entity except, AABB bb, + Predicate selector, int kind) { + TickStats.rareLookups++; + Profiler.get().incrementCounter("getEntities"); + List output = Lists.newArrayList(); + AbortableIterationConsumer> visitor = section -> { + for (EntityAccess access : ((RareEntitySection) section).vesuvio$rare(kind)) { + Entity entity = (Entity) access; + if (entity.getBoundingBox().intersects(bb) && entity != except && selector.test(entity)) { + output.add(entity); + } + } + return Continuation.CONTINUE; + }; + storage.forEachAccessibleNonEmptySection(bb, visitor); + for (EnderDragonPart dragonPart : level.dragonParts()) { + if (dragonPart != except && dragonPart.parentMob != except && selector.test(dragonPart) + && bb.intersects(dragonPart.getBoundingBox())) { + output.add(dragonPart); + } + } + return output; + } + + public static List empty() { + return Collections.emptyList(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/RareEntitySection.java b/src/main/java/it/cosimo/vesuvio/ticks/RareEntitySection.java new file mode 100644 index 0000000..03ebc35 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/RareEntitySection.java @@ -0,0 +1,11 @@ +package it.cosimo.vesuvio.ticks; + +import java.util.List; + +import net.minecraft.world.level.entity.EntityAccess; + +/** Implemented by EntitySection (mixin): the section's rare entities of one kind, in the section's own order. */ +public interface RareEntitySection { + /** Entities of the given {@link RareEntities} kind, in insertion order; empty list when none. Never modify it. */ + List vesuvio$rare(int kind); +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/SectionLookup.java b/src/main/java/it/cosimo/vesuvio/ticks/SectionLookup.java new file mode 100644 index 0000000..aabfa16 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/SectionLookup.java @@ -0,0 +1,84 @@ +package it.cosimo.vesuvio.ticks; + +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import net.minecraft.core.SectionPos; +import net.minecraft.util.AbortableIterationConsumer; +import net.minecraft.world.level.entity.EntityAccess; +import net.minecraft.world.level.entity.EntitySection; +import net.minecraft.world.phys.AABB; + +/** + * EntitySectionStorage.forEachAccessibleNonEmptySection without the sorted-set walk. + * + *

Vanilla, for each section column x in range, takes {@code sectionIds.subSet(asLong(x,0,0), asLong(x,-1,-1)+1)} + * (a LongAVLTreeSet view plus an iterator) and walks every existing section of that column in the whole world, + * keeping the ones whose y and z are in range. We look the in-range keys up in the {@code sections} map directly, + * in exactly the order the sorted walk would meet them, so the consumer sees the same sections in the same order. + * + *

Order of the sorted walk: SectionPos.asLong packs x in the top 22 bits, z in the next 22, y in the low 20 + * (two's complement, masked). Within one column the top bits are equal, so the signed long order is the unsigned + * order of {@code (z & 0x3FFFFF) << 20 | (y & 0xFFFFF)}: z first, then y, and in each the non-negative values + * ascending come before the negative values ascending (e.g. z = 0, 1, 2, -3, -2, -1). {@code sections} and + * {@code sectionIds} always hold the same keys (EntitySectionStorage.createSection / remove change both). + */ +public final class SectionLookup { + /** Above this many (x, y, z) keys the sorted walk is cheaper (huge boxes: beacons, raids, commands). */ + private static final long MAX_KEYS = 64; + private static final int XZ_LIMIT = 1 << 21; + private static final int Y_LIMIT = 1 << 19; + + private SectionLookup() { + } + + /** + * Visits the accessible non-empty sections the vanilla method would visit, in its order. + * + * @return false when the box is too large or too far out for the direct lookup; the caller then runs vanilla + */ + public static boolean forEach(Long2ObjectMap> sections, AABB bb, + AbortableIterationConsumer> output) { + // Same bounds as vanilla, same functions. + int xMin = SectionPos.posToSectionCoord(bb.minX - 2.0); + int yMin = SectionPos.posToSectionCoord(bb.minY - 4.0); + int zMin = SectionPos.posToSectionCoord(bb.minZ - 2.0); + int xMax = SectionPos.posToSectionCoord(bb.maxX + 2.0); + int yMax = SectionPos.posToSectionCoord(bb.maxY + 0.0); + int zMax = SectionPos.posToSectionCoord(bb.maxZ + 2.0); + if (xMin > xMax || yMin > yMax || zMin > zMax) { + // Vanilla visits nothing either (the x loop is empty, or the y/z filter rejects every key). + return true; + } + // Packing must be one-to-one and order-preserving for the order argument above. (At x = 2^21 - 1 vanilla's + // subSet bound overflows and throws; such boxes, 33 million blocks out, are left to vanilla.) + if (xMin < -XZ_LIMIT || xMax >= XZ_LIMIT - 1 || zMin < -XZ_LIMIT || zMax >= XZ_LIMIT || yMin < -Y_LIMIT + || yMax >= Y_LIMIT) { + return false; + } + long keys = (long) (xMax - xMin + 1) * (long) (yMax - yMin + 1) * (long) (zMax - zMin + 1); + if (keys > MAX_KEYS) { + return false; + } + TickStats.sectionLookups++; + for (int x = xMin; x <= xMax; x++) { + // z: non-negative values first, then negative ones (unsigned order of the masked value). + for (int zPass = 0; zPass < 2; zPass++) { + int zFrom = zPass == 0 ? Math.max(zMin, 0) : zMin; + int zTo = zPass == 0 ? zMax : Math.min(zMax, -1); + for (int z = zFrom; z <= zTo; z++) { + for (int yPass = 0; yPass < 2; yPass++) { + int yFrom = yPass == 0 ? Math.max(yMin, 0) : yMin; + int yTo = yPass == 0 ? yMax : Math.min(yMax, -1); + for (int y = yFrom; y <= yTo; y++) { + EntitySection section = sections.get(SectionPos.asLong(x, y, z)); + if (section != null && !section.isEmpty() && section.getStatus().isAccessible() + && output.accept(section).shouldAbort()) { + return true; + } + } + } + } + } + } + return true; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/TickStats.java b/src/main/java/it/cosimo/vesuvio/ticks/TickStats.java new file mode 100644 index 0000000..6843bde --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/TickStats.java @@ -0,0 +1,23 @@ +package it.cosimo.vesuvio.ticks; + +/** + * Counters of the "world" optimizations, for the settings screen / benchmark. Plain statics, only ever + * incremented (from the server thread and the client thread, so a few increments can be lost: they are statistics, + * not state). Read them, take differences over time. + */ +public final class TickStats { + /** Entity lookups answered by visiting the sections directly (instead of walking the sorted column). */ + public static long sectionLookups; + /** Entity-collision and hopper-container lookups answered from the rare-entity lists. */ + public static long rareLookups; + /** Path type requests answered from the per-state memo / computed and stored. */ + public static long pathTypeHits; + public static long pathTypeMisses; + /** POI searches narrowed to the sub-chunks the sphere touches. */ + public static long poiSearches; + /** Suffocation checks done with the loop version. */ + public static long suffocationChecks; + + private TickStats() { + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/WorldTicks.java b/src/main/java/it/cosimo/vesuvio/ticks/WorldTicks.java new file mode 100644 index 0000000..b3781b6 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/WorldTicks.java @@ -0,0 +1,49 @@ +package it.cosimo.vesuvio.ticks; + +import it.cosimo.vesuvio.VesuvioConfig; + +/** + * Game-logic speedups ("world" page): the integrated server of a single-player world and the client tick run + * the same vanilla code with less work. Every technique gives exactly the vanilla result (same entities in the + * same order, same path types, same POI records, same random numbers); see report-ticks.md for the proofs. + * In multiplayer the server-side ones simply never run (the server is someone else's); the client-side users of + * the same code (entity lookups, the local player's collisions) still get faster. + * + *

Counters for the settings screen / benchmark are in {@link TickStats}. + */ +public final class WorldTicks { + /** Entity lookups visit only the entity sections inside the search box (vanilla walks a whole sorted column). */ + public static final VesuvioConfig.Toggle SECTION_LOOKUP = VesuvioConfig.toggle("world", "worldEntitySectionLookup", true, + false); + /** + * Each entity section keeps a short list of its "rare" entities (boats, minecarts, shulkers, happy ghasts: + * the only ones anything can collide with; container entities: what hoppers look for), so entity-vs-entity + * collision checks and hopper container checks skip the cows, items and zombies. Restart: the lists are + * filled as entities enter sections. + */ + public static final VesuvioConfig.Toggle RARE_ENTITY_INDEX = VesuvioConfig.toggle("world", "worldRareEntityIndex", true, + true); + /** Remembers each block state's pathfinding type (it only depends on the state and the tags). */ + public static final VesuvioConfig.Toggle PATH_TYPE_CACHE = VesuvioConfig.toggle("world", "worldPathTypeCache", true, + false); + /** Spherical POI searches (villagers, bees, golems) skip the sub-chunks above and below the sphere. */ + public static final VesuvioConfig.Toggle POI_RANGE = VesuvioConfig.toggle("world", "worldPoiVerticalRange", true, false); + /** The per-tick "inside a wall?" check of every mob uses plain loops instead of a stream. */ + public static final VesuvioConfig.Toggle SUFFOCATION = VesuvioConfig.toggle("world", "worldFastSuffocationCheck", true, + false); + + private WorldTicks() { + } + + /** Called from the client entry point so the settings exist (and the restart-only index decides) early. */ + public static void init() { + RareEntities.init(); + // F3, the settings screen's box, the stats log and the benchmark's counter table. + it.cosimo.vesuvio.ui.VesuvioStats.register("path type memo hits %", () -> { + long hits = TickStats.pathTypeHits; + long all = hits + TickStats.pathTypeMisses; + return all == 0 ? 0 : (int) (hits * 100 / all); + }); + it.cosimo.vesuvio.ui.VesuvioStats.register("entity lookups (k)", () -> (int) (TickStats.sectionLookups / 1000)); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/BlockStatePathTypeMixin.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/BlockStatePathTypeMixin.java new file mode 100644 index 0000000..d5bc638 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/BlockStatePathTypeMixin.java @@ -0,0 +1,25 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; + +import it.cosimo.vesuvio.ticks.PathTypeMemo; +import net.minecraft.world.level.block.state.BlockBehaviour; + +/** Setting worldPathTypeCache: the per-state slot for {@link PathTypeMemo}. */ +@Mixin(BlockBehaviour.BlockStateBase.class) +abstract class BlockStatePathTypeMixin implements PathTypeMemo.Holder { + @Unique + private PathTypeMemo.@Nullable Entry vesuvio$pathType; + + @Override + public PathTypeMemo.@Nullable Entry vesuvio$pathType() { + return this.vesuvio$pathType; + } + + @Override + public void vesuvio$setPathType(PathTypeMemo.Entry entry) { + this.vesuvio$pathType = entry; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/EntitySectionMixin.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/EntitySectionMixin.java new file mode 100644 index 0000000..5c8334c --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/EntitySectionMixin.java @@ -0,0 +1,70 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import java.util.ArrayList; +import java.util.List; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import it.cosimo.vesuvio.ticks.RareEntities; +import it.cosimo.vesuvio.ticks.RareEntitySection; +import net.minecraft.world.level.entity.EntityAccess; +import net.minecraft.world.level.entity.EntitySection; + +/** + * Setting worldRareEntityIndex (restart): next to its ClassInstanceMultiMap, each section keeps the sub-lists of + * {@link RareEntities} kinds. add() appends to the multimap's list and to ours; remove() does List.remove(Object) + * on the multimap's list and on ours. Both lists see the same calls in the same order, so ours is always the + * multimap's list filtered by class, in the same order. Entity sections are only touched on the thread that owns + * the level (server thread / client thread), like the multimap itself. + */ +@Mixin(EntitySection.class) +abstract class EntitySectionMixin implements RareEntitySection { + /** Per kind, null until the first entity of that kind arrives. Null array: none yet / index off. */ + @Unique + private List[] vesuvio$rareLists; + + @Inject(method = "add", at = @At("TAIL")) + @SuppressWarnings("unchecked") + private void vesuvio$addRare(EntityAccess entity, CallbackInfo ci) { + if (!RareEntities.MAINTAIN) { + return; + } + int kinds = RareEntities.kinds(entity); + if (kinds != 0 && this.vesuvio$rareLists == null) { + this.vesuvio$rareLists = new List[RareEntities.KINDS]; + } + for (int k = 0; kinds != 0; k++, kinds >>>= 1) { + if ((kinds & 1) != 0) { + List list = this.vesuvio$rareLists[k]; + if (list == null) { + list = this.vesuvio$rareLists[k] = new ArrayList<>(2); + } + list.add(entity); + } + } + } + + @Inject(method = "remove", at = @At("RETURN")) + private void vesuvio$removeRare(EntityAccess entity, CallbackInfoReturnable cir) { + if (this.vesuvio$rareLists == null) { + return; + } + int kinds = RareEntities.kinds(entity); + for (int k = 0; kinds != 0; k++, kinds >>>= 1) { + if ((kinds & 1) != 0 && this.vesuvio$rareLists[k] != null) { + this.vesuvio$rareLists[k].remove(entity); + } + } + } + + @Override + public List vesuvio$rare(int kind) { + List list = this.vesuvio$rareLists == null ? null : this.vesuvio$rareLists[kind]; + return list == null ? RareEntities.empty() : list; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/EntitySectionStorageMixin.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/EntitySectionStorageMixin.java new file mode 100644 index 0000000..6984520 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/EntitySectionStorageMixin.java @@ -0,0 +1,36 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.ticks.SectionLookup; +import it.cosimo.vesuvio.ticks.WorldTicks; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import net.minecraft.util.AbortableIterationConsumer; +import net.minecraft.world.level.entity.EntityAccess; +import net.minecraft.world.level.entity.EntitySection; +import net.minecraft.world.level.entity.EntitySectionStorage; +import net.minecraft.world.phys.AABB; + +/** + * Setting worldEntitySectionLookup: every box-based entity lookup (Level.getEntities, getEntitiesOfClass, entity + * collisions, pushing, hoppers picking up items, AI sensors...) goes through forEachAccessibleNonEmptySection, on + * the server thread and on the client thread. See {@link SectionLookup}: same sections, same order. + */ +@Mixin(EntitySectionStorage.class) +abstract class EntitySectionStorageMixin { + @Shadow + @Final + private Long2ObjectMap> sections; + + @Inject(method = "forEachAccessibleNonEmptySection", at = @At("HEAD"), cancellable = true) + private void vesuvio$directLookup(AABB bb, AbortableIterationConsumer> output, CallbackInfo ci) { + if (WorldTicks.SECTION_LOOKUP.on() && SectionLookup.forEach(this.sections, bb, output)) { + ci.cancel(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/EntitySuffocationMixin.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/EntitySuffocationMixin.java new file mode 100644 index 0000000..be94b83 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/EntitySuffocationMixin.java @@ -0,0 +1,84 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import it.cosimo.vesuvio.ticks.TickStats; +import it.cosimo.vesuvio.ticks.WorldTicks; +import net.minecraft.core.BlockPos; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.shapes.BooleanOp; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Setting worldFastSuffocationCheck: Entity.isInWall runs for every living entity every server tick + * (LivingEntity.baseTick). Vanilla builds a Stream over BlockPos.betweenClosed(eye box) and anyMatch-es a + * capturing lambda. This is the same test with plain loops: same box (same AABB.ofSize call), same blocks in the + * same order (betweenClosed: x fastest, then y, then z), same per-block test, stopping at the first hit. The + * per-block test only reads the world (getBlockState, isSuffocating, collision shape), so nothing else changes. + */ +@Mixin(Entity.class) +abstract class EntitySuffocationMixin { + @Shadow + public boolean noPhysics; + + @Shadow + private EntityDimensions dimensions; + + @Shadow + public abstract Level level(); + + /** + * Kept tiny (the loop is in {@link #vesuvio$inWall}) so the JIT inlines it into isInWall and can drop the + * CallbackInfoReturnable it would otherwise allocate per call. + */ + @Inject(method = "isInWall", at = @At("HEAD"), cancellable = true) + private void vesuvio$loopInWall(CallbackInfoReturnable cir) { + if (WorldTicks.SUFFOCATION.on() && !this.noPhysics) { + cir.setReturnValue(this.vesuvio$inWall()); + } + } + + @Unique + private boolean vesuvio$inWall() { + TickStats.suffocationChecks++; + float checkWidth = this.dimensions.width() * 0.8F; + AABB eyeBb = AABB.ofSize(((Entity) (Object) this).getEyePosition(), checkWidth, 1.0E-6, checkWidth); + int minX = Mth.floor(eyeBb.minX); + int minY = Mth.floor(eyeBb.minY); + int minZ = Mth.floor(eyeBb.minZ); + int maxX = Mth.floor(eyeBb.maxX); + int maxY = Mth.floor(eyeBb.maxY); + int maxZ = Mth.floor(eyeBb.maxZ); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + VoxelShape eyeShape = null; + for (int z = minZ; z <= maxZ; z++) { + for (int y = minY; y <= maxY; y++) { + for (int x = minX; x <= maxX; x++) { + pos.set(x, y, z); + Level level = this.level(); + BlockState state = level.getBlockState(pos); + if (!state.isAir() && state.isSuffocating(level, pos)) { + if (eyeShape == null) { + eyeShape = Shapes.create(eyeBb); + } + if (Shapes.joinIsNotEmpty(state.getCollisionShape(level, pos).move(pos), eyeShape, BooleanOp.AND)) { + return true; + } + } + } + } + } + return false; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/HopperBlockEntityMixin.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/HopperBlockEntityMixin.java new file mode 100644 index 0000000..4a50d6b --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/HopperBlockEntityMixin.java @@ -0,0 +1,43 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import java.util.List; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import it.cosimo.vesuvio.ticks.LevelEntityStorage; +import it.cosimo.vesuvio.ticks.RareEntities; +import net.minecraft.world.Container; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySelector; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.HopperBlockEntity; +import net.minecraft.world.level.entity.EntitySectionStorage; +import net.minecraft.world.phys.AABB; + +/** + * Setting worldRareEntityIndex: a hopper with no block container above it (or in front of it) looks for a + * container entity (chest/hopper minecart, chest boat) every tick. Vanilla walks every entity around; the + * container list holds exactly the entities that can pass {@code instanceof Container}, in the same order, so the + * list, and the random pick from it (same size, same single nextInt call), are vanilla's. + */ +@Mixin(HopperBlockEntity.class) +abstract class HopperBlockEntityMixin { + @Inject(method = "getEntityContainer", at = @At("HEAD"), cancellable = true) + private static void vesuvio$rareContainers(Level level, double x, double y, double z, + CallbackInfoReturnable cir) { + if (!RareEntities.active()) { + return; + } + EntitySectionStorage storage = ((LevelEntityStorage) level).vesuvio$entityStorage(); + if (storage == null) { + return; + } + List entities = RareEntities.getEntities(level, storage, null, + new AABB(x - 0.5, y - 0.5, z - 0.5, x + 0.5, y + 0.5, z + 0.5), EntitySelector.CONTAINER_ENTITY_SELECTOR, + RareEntities.CONTAINER); + cir.setReturnValue(!entities.isEmpty() ? (Container) entities.get(level.getRandom().nextInt(entities.size())) : null); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/LevelEntityGetterAdapterAccessor.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/LevelEntityGetterAdapterAccessor.java new file mode 100644 index 0000000..4b53979 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/LevelEntityGetterAdapterAccessor.java @@ -0,0 +1,14 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +import net.minecraft.world.level.entity.EntitySectionStorage; +import net.minecraft.world.level.entity.LevelEntityGetterAdapter; + +/** The section storage behind a level's entity getter (server: PersistentEntitySectionManager, client: Transient). */ +@Mixin(LevelEntityGetterAdapter.class) +public interface LevelEntityGetterAdapterAccessor { + @Accessor("sectionStorage") + EntitySectionStorage vesuvio$sectionStorage(); +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/LevelMixin.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/LevelMixin.java new file mode 100644 index 0000000..9e297d6 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/LevelMixin.java @@ -0,0 +1,79 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import java.util.List; +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableList.Builder; + +import it.cosimo.vesuvio.ticks.LevelEntityStorage; +import it.cosimo.vesuvio.ticks.RareEntities; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySelector; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.entity.EntitySectionStorage; +import net.minecraft.world.level.entity.LevelEntityGetter; +import net.minecraft.world.level.entity.LevelEntityGetterAdapter; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.shapes.Shapes; +import net.minecraft.world.phys.shapes.VoxelShape; + +/** + * Setting worldRareEntityIndex: Level gets its own getEntityCollisions (vanilla Level inherits the default from + * EntityGetter via CommonLevelAccessor; no vanilla subclass overrides it). Its body is the vanilla default, line + * for line, except where the entity list comes from: when the index is on and the source uses the plain + * Entity.canCollideWith, only the sections' collidable lists are walked ({@link RareEntities#getEntities}). + * Callers: Entity.collide (every moving entity, both sides), CollisionGetter.noCollision / noEntityCollision + * (item entities every tick, spawning, teleports...), getCollisions, getPreMoveCollisions. + */ +@Mixin(Level.class) +abstract class LevelMixin implements LevelEntityStorage { + @Shadow + protected abstract LevelEntityGetter getEntities(); + + @Shadow + public abstract List getEntities(@Nullable Entity except, AABB bb, Predicate selector); + + @Override + @SuppressWarnings("unchecked") + public EntitySectionStorage vesuvio$entityStorage() { + LevelEntityGetter getter = this.getEntities(); + return getter instanceof LevelEntityGetterAdapter + ? (EntitySectionStorage) ((LevelEntityGetterAdapterAccessor) getter).vesuvio$sectionStorage() + : null; + } + + /** Overrides EntityGetter.getEntityCollisions (the vanilla default, copied, with the faster entity list). */ + public List getEntityCollisions(@Nullable final Entity source, final AABB testArea) { + if (testArea.getSize() < 1.0E-7) { + return List.of(); + } else { + Predicate canCollide = source == null ? EntitySelector.CAN_BE_COLLIDED_WITH + : EntitySelector.NO_SPECTATORS.and(source::canCollideWith); + List collidingEntities; + EntitySectionStorage storage; + if (RareEntities.active() && RareEntities.plainCollider(source) + && (storage = this.vesuvio$entityStorage()) != null) { + collidingEntities = RareEntities.getEntities((Level) (Object) this, storage, source, + testArea.inflate(1.0E-7), canCollide, RareEntities.COLLIDABLE); + } else { + collidingEntities = this.getEntities(source, testArea.inflate(1.0E-7), canCollide); + } + if (collidingEntities.isEmpty()) { + return List.of(); + } else { + Builder shapes = ImmutableList.builderWithExpectedSize(collidingEntities.size()); + + for (Entity entity : collidingEntities) { + shapes.add(Shapes.create(entity.getBoundingBox())); + } + + return shapes.build(); + } + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/MappedRegistryMixin.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/MappedRegistryMixin.java new file mode 100644 index 0000000..2eb8a77 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/MappedRegistryMixin.java @@ -0,0 +1,28 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.ticks.PathTypeMemo; +import net.minecraft.core.MappedRegistry; + +/** + * Setting worldPathTypeCache: refreshTagsInHolders is where every registry rebinds each holder's tag set (the set + * BlockState.is(TagKey) and FluidState.is(TagKey) read), on freeze and on every tag reload. Bumping the memo's + * generation at both ends invalidates anything computed before or during the rebind. Runs always (it only + * increments a counter), so turning the setting on later never sees stale entries. + */ +@Mixin(MappedRegistry.class) +abstract class MappedRegistryMixin { + @Inject(method = "refreshTagsInHolders", at = @At("HEAD")) + private void vesuvio$tagsChanging(CallbackInfo ci) { + PathTypeMemo.tagsChanged(); + } + + @Inject(method = "refreshTagsInHolders", at = @At("TAIL")) + private void vesuvio$tagsChanged(CallbackInfo ci) { + PathTypeMemo.tagsChanged(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/PoiManagerMixin.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/PoiManagerMixin.java new file mode 100644 index 0000000..b17ed2e --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/PoiManagerMixin.java @@ -0,0 +1,68 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import it.cosimo.vesuvio.ticks.TickStats; +import it.cosimo.vesuvio.ticks.WorldTicks; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.SectionPos; +import net.minecraft.world.entity.ai.village.poi.PoiManager; +import net.minecraft.world.entity.ai.village.poi.PoiRecord; +import net.minecraft.world.entity.ai.village.poi.PoiSection; +import net.minecraft.world.entity.ai.village.poi.PoiType; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; + +/** + * Setting worldPoiVerticalRange. PoiManager.getInRange (behind find, findClosest, findAll, take, getRandom, + * getCountInRange: villagers' job/bed/meeting searches, bees' hive search, golem and raid checks) is + * getInSquare (every chunk in the square, every sub-chunk of the world height, bottom to top) followed by the + * filter {@code distSqr(center) <= radius * radius}. A record in a sub-chunk entirely above center.y + radius or + * below center.y - radius has |dy| > radius, so distSqr >= dy^2 > radius^2 and the filter drops it. We build the + * same stream (same chunk order, same sub-chunk order, same filters, same laziness) over only the sub-chunks the + * sphere's y range touches: identical records in identical order. Every chunk still gets at least one + * getOrLoad, at the same point of the walk, so POI data is loaded exactly when vanilla loads it. Cases the + * argument does not cover (radius too large or negative, y range outside the world, nothing to skip) run vanilla. + */ +@Mixin(PoiManager.class) +abstract class PoiManagerMixin { + @Inject(method = "getInRange", at = @At("HEAD"), cancellable = true) + private void vesuvio$verticalRange(Predicate> predicate, BlockPos center, int radius, + PoiManager.Occupancy occupancy, CallbackInfoReturnable> cir) { + if (!WorldTicks.POI_RANGE.on() || radius < 0 || radius > 46340) { + return; // 46340^2 still fits an int: radius * radius below is the exact square, as in vanilla + } + SectionStorageAccess storage = (SectionStorageAccess) this; + LevelHeightAccessor height = storage.vesuvio$levelHeightAccessor(); + int minSection = height.getMinSectionY(); + int maxSection = height.getMaxSectionY(); + int from = Math.max(minSection, SectionPos.blockToSectionCoord(center.getY() - radius)); + int to = Math.min(maxSection, SectionPos.blockToSectionCoord(center.getY() + radius)); + if (from > to || (from == minSection && to == maxSection)) { + return; + } + TickStats.poiSearches++; + int radiusSqr = radius * radius; + int chunkRadius = Math.floorDiv(radius, 16) + 1; + cir.setReturnValue(ChunkPos.rangeClosed(ChunkPos.containing(center), chunkRadius) + .flatMap(chunk -> IntStream.rangeClosed(from, to) + .boxed() + .map(sectionY -> storage.vesuvio$getOrLoad(SectionPos.of(chunk, sectionY).asLong())) + .filter(Optional::isPresent) + .flatMap(section -> ((PoiSection) section.get()).getRecords(predicate, occupancy))) + .filter(record -> { + BlockPos pos = record.getPos(); + return Math.abs(pos.getX() - center.getX()) <= radius && Math.abs(pos.getZ() - center.getZ()) <= radius; + }) + .filter(r -> r.getPos().distSqr(center) <= radiusSqr)); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/SectionStorageAccess.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/SectionStorageAccess.java new file mode 100644 index 0000000..ae6222c --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/SectionStorageAccess.java @@ -0,0 +1,20 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import java.util.Optional; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; + +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.chunk.storage.SectionStorage; + +/** PoiManager's inherited section access, for {@link PoiManagerMixin}. */ +@Mixin(SectionStorage.class) +public interface SectionStorageAccess { + @Invoker("getOrLoad") + Optional vesuvio$getOrLoad(long sectionPos); + + @Accessor("levelHeightAccessor") + LevelHeightAccessor vesuvio$levelHeightAccessor(); +} diff --git a/src/main/java/it/cosimo/vesuvio/ticks/mixin/WalkNodeEvaluatorMixin.java b/src/main/java/it/cosimo/vesuvio/ticks/mixin/WalkNodeEvaluatorMixin.java new file mode 100644 index 0000000..cea7e4b --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ticks/mixin/WalkNodeEvaluatorMixin.java @@ -0,0 +1,29 @@ +package it.cosimo.vesuvio.ticks.mixin; + +import org.spongepowered.asm.mixin.Mixin; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; + +import it.cosimo.vesuvio.ticks.PathTypeMemo; +import it.cosimo.vesuvio.ticks.WorldTicks; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.pathfinder.PathType; +import net.minecraft.world.level.pathfinder.WalkNodeEvaluator; + +/** + * Setting worldPathTypeCache: getPathTypeFromState is the per-block step of all ground/amphibious pathfinding + * (through ServerLevel's PathTypeCache misses and the neighbour scan of every candidate node). See + * {@link PathTypeMemo}. + */ +@Mixin(WalkNodeEvaluator.class) +abstract class WalkNodeEvaluatorMixin { + @WrapMethod(method = "getPathTypeFromState") + private static PathType vesuvio$memo(BlockGetter level, BlockPos pos, Operation original) { + if (!WorldTicks.PATH_TYPE_CACHE.on()) { + return original.call(level, pos); + } + return PathTypeMemo.get(level, pos, original); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/VesuvioBenchmark.java b/src/main/java/it/cosimo/vesuvio/ui/VesuvioBenchmark.java new file mode 100644 index 0000000..f1b021e --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/VesuvioBenchmark.java @@ -0,0 +1,594 @@ +package it.cosimo.vesuvio.ui; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.Vesuvio; +import it.cosimo.vesuvio.draws.SubmitTimers; +import it.cosimo.vesuvio.frame.GpuPassTimers; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.renderer.chunk.SectionRenderDispatcher; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +/** + * The "Run benchmark" button on the General page. With the player standing still it measures, for + * {@link VesuvioUi#BENCHMARK_SECONDS} each after 3 s to settle: Vesuvio off, on, then each live + * optimization setting changed on its own (every setting off the General page that needs no restart: a + * toggle is flipped, a number goes to its lowest value, e.g. "leafCulling 0", or to its default if it is + * at its lowest already), on again and off again. "Off" and "on" run at both ends, so how far the two + * land apart is the noise. + * + * Frame time is the real interval between the starts of two frames on the render thread + * (Minecraft.runTick, see MinecraftUiMixin): it includes waiting for the GPU and for the swapchain, which + * the game's own frame timer (getFrameTimeNs, the "cpu" column) leaves out. The "encode" and "wait" columns split the + * end of the frame (SubmitTimers): time inside vkQueueSubmit (on macOS, MoltenVK's Metal encoding) and time waiting + * for the GPU to finish an earlier frame, both in ms per frame. + * + * Right after "Vesuvio on" comes one more step, "GPU pass timers": the same settings with gpuPassTimers on + * (GpuPassTimers), which logs a "GPU passes" table: the graphics card's time per frame for each render pass (by its + * label), uploads and the rest. gpuPassTimers is off in every other step whatever the player set (it is a diagnostic + * and costs a little), so its own step's "vs on" is what measuring costs; it is put back at the end. The step is left + * out when the graphics driver cannot write timestamps. + * + * The settings are only changed in memory and never saved during a run; every switch is put back when it + * ends, and when it is stopped: any key or mouse button pressed, the player moving or turning, a menu or + * loading screen opening, the window minimized, the world left, or an error. A stopped run still logs the + * steps it finished, and shows them when it was stopped by a key or a move. While it runs, the action bar + * shows the step and the time left. Movement in the first moments is ignored, while the player may still be + * coasting from closing the menu. + */ +public final class VesuvioBenchmark { + /** Why a run ended. */ + public enum Stop { + DONE, INPUT, MOVED, MENU, MINIMIZED, LEFT_WORLD, ERROR, OTHER; + + String key() { + return "vesuvio.benchmark.stop." + name().toLowerCase(Locale.ROOT); + } + } + + private static final long SETTLE_NS = 3_000_000_000L; + /** + * Settling goes on past {@link #SETTLE_NS} while chunk sections are still queued for compiling (a step that + * changes how meshes are built, e.g. the master switch with leaf culling, rebuilds every loaded section), up + * to this long. + */ + private static final long MAX_SETTLE_NS = 30_000_000_000L; + /** Camera travel (blocks) or turn (degrees) since the start above which the run stops. */ + private static final double MOVE_LIMIT = 0.3; + private static final float TURN_LIMIT = 1.0F; + /** + * At the start of the first step the player may still be coasting from the moment the menu closed: for this + * long the starting position follows the camera instead of stopping the run. + */ + private static final long COAST_NS = 1_500_000_000L; + /** Frame intervals kept per step for the 95th percentile; more frames than this are only averaged. */ + private static final int MAX_FRAMES = 1 << 18; + + /** + * One step: its name in the table, and what it sets on top of the player's settings: the toggle it + * flips, or the number setting it sets to {@code numberValue}. + */ + private record Step(String name, Kind kind, VesuvioConfig.@Nullable Toggle flipped, + VesuvioConfig.@Nullable Number number, int numberValue) { + Step(String name, Kind kind, VesuvioConfig.@Nullable Toggle flipped) { + this(name, kind, flipped, null, 0); + } + } + + private enum Kind { + OFF, ON, FLIP, + /** The player's settings with gpuPassTimers on, for the GPU passes table. */ + TIMERS + } + + /** One measured step. */ + record Result(String name, int frames, double meanMs, double fps, double p95Ms, double cpuMs, double encodeMs, + double waitMs, int[] counters) { + } + + private static @Nullable List steps; + private static List toggles = List.of(); + /** The player's own values of {@link #toggles}, put back at the end. */ + private static boolean[] saved = new boolean[0]; + private static List numbers = List.of(); + /** The player's own values of {@link #numbers}, put back at the end. */ + private static int[] savedNumbers = new int[0]; + /** The player's own gpuPassTimers, put back at the end (not in {@link #toggles}: the steps set it). */ + private static boolean savedGpuTimers; + /** GpuPassTimers' totals when the step's measuring began. */ + private static GpuPassTimers.@Nullable Totals gpuStart; + /** The "GPU passes" table of the TIMERS step, once measured. */ + private static @Nullable String gpuTable; + private static Result[] results = new Result[0]; + private static List stats = List.of(); + private static @Nullable ClientLevel level; + private static double startX; + private static double startY; + private static double startZ; + private static float startYRot; + private static float startXRot; + private static long measureNs; + private static int step = -1; + private static boolean settling; + private static long phaseStart; + private static long lastFrame; + private static float @Nullable [] frames; + private static int frameCount; + private static double frameSumNs; + private static double cpuSumNs; + /** SubmitTimers' totals when the step's measuring began. */ + private static long encodeStartNs; + private static long waitStartNs; + private static long submitsStart; + /** When the action bar countdown was last refreshed. */ + private static long lastCountdown; + /** + * Results of a run stopped by a key or a click, shown on the next frame so that the key or click itself does + * not reach (and close) the results screen. + */ + private static @Nullable VesuvioBenchmarkScreen pendingScreen; + + private VesuvioBenchmark() { + } + + public static boolean running() { + return steps != null; + } + + /** The step under way, for the F3 line; "" when no run is going. */ + public static String stepName() { + List s = steps; + return s == null || step < 0 || step >= s.size() ? "" : (step + 1) + "/" + s.size() + " " + s.get(step).name; + } + + /** Called by the settings screen's button after it closed the menu. */ + public static void start() { + Minecraft mc = Minecraft.getInstance(); + if (running() || mc.level == null || mc.player == null) { + return; + } + List all = new ArrayList<>(); + List allNumbers = new ArrayList<>(); + for (VesuvioConfig.Setting s : VesuvioConfig.all()) { + if (s instanceof VesuvioConfig.Toggle t && t != GpuPassTimers.SETTING) { + all.add(t); + } else if (s instanceof VesuvioConfig.Number n && !n.needsRestart && !"general".equals(n.page)) { + allNumbers.add(n); + } + } + toggles = List.copyOf(all); + savedGpuTimers = GpuPassTimers.SETTING.get(); + gpuStart = null; + gpuTable = null; + saved = new boolean[toggles.size()]; + for (int i = 0; i < toggles.size(); i++) { + saved[i] = toggles.get(i).get(); + } + numbers = List.copyOf(allNumbers); + savedNumbers = new int[numbers.size()]; + for (int i = 0; i < numbers.size(); i++) { + savedNumbers[i] = numbers.get(i).get(); + } + List list = new ArrayList<>(); + list.add(new Step("Vesuvio off", Kind.OFF, null)); + list.add(new Step("Vesuvio on", Kind.ON, null)); + if (!GpuPassTimers.unsupported()) { + list.add(new Step("GPU pass timers", Kind.TIMERS, null)); + } + for (VesuvioConfig.Toggle t : toggles) { + if (t != VesuvioConfig.ENABLED && !t.needsRestart && !"general".equals(t.page)) { + list.add(new Step(t.key + (t.get() ? " off" : " on"), Kind.FLIP, t)); + } + } + for (VesuvioConfig.Number n : numbers) { + // Lowest value (the optimization's "off" or mildest, e.g. leafCulling 0), or the default if already there. + int value = n.get() != n.min ? n.min : n.defaultValue; + if (value != n.get()) { + list.add(new Step(n.key + " " + value, Kind.FLIP, null, n, value)); + } + } + list.add(new Step("Vesuvio on, again", Kind.ON, null)); + list.add(new Step("Vesuvio off, again", Kind.OFF, null)); + stats = VesuvioStats.all(); + results = new Result[list.size()]; + frames = new float[MAX_FRAMES]; + measureNs = VesuvioUi.BENCHMARK_SECONDS.get() * 1_000_000_000L; + level = mc.level; + LocalPlayer p = mc.player; + startX = p.getX(); + startY = p.getY(); + startZ = p.getZ(); + startYRot = p.getYRot(); + startXRot = p.getXRot(); + step = -1; + lastFrame = 0L; + lastCountdown = 0L; + pendingScreen = null; + steps = list; + long seconds = list.size() * (SETTLE_NS / 1_000_000_000L + measureNs / 1_000_000_000L); + Vesuvio.LOGGER.info("Vesuvio benchmark started: {} steps, {} s each after {} s to settle, about {} s. {}", + list.size(), measureNs / 1_000_000_000L, SETTLE_NS / 1_000_000_000L, seconds, setup(mc)); + mc.showDebugChat(Component.translatable("vesuvio.benchmark.started", list.size(), seconds)); + } + + /** Start of every frame (Minecraft.runTick), render thread. Cheap when no run is going. */ + public static void frame() { + if (steps == null) { + if (pendingScreen != null) { + VesuvioBenchmarkScreen screen = pendingScreen; + pendingScreen = null; + Minecraft mc = Minecraft.getInstance(); + if (mc.level != null) { + mc.gui.setScreen(screen); + } + } + return; + } + try { + Minecraft mc = Minecraft.getInstance(); + long now = System.nanoTime(); + long interval = lastFrame == 0L ? 0L : now - lastFrame; + lastFrame = now; + Stop reason = interruption(mc); + if (reason != null) { + stop(reason); + return; + } + // The player is asked not to touch anything: keep the game's AFK frame limit out of the run. + mc.getFramerateLimitTracker().onInputReceived(); + if (step < 0) { + begin(0, now); + return; + } + if (now - lastCountdown >= 1_000_000_000L) { + lastCountdown = now; + countdown(mc, now); + } + if (settling) { + long settled = now - phaseStart; + if (settled >= SETTLE_NS && (settled >= MAX_SETTLE_NS || compileQueue(mc) == 0)) { + settling = false; + phaseStart = now; + frameCount = 0; + frameSumNs = 0.0; + cpuSumNs = 0.0; + encodeStartNs = SubmitTimers.encodeNsTotal; + waitStartNs = SubmitTimers.waitNsTotal; + submitsStart = SubmitTimers.submits; + gpuStart = GpuPassTimers.totals(); + } + return; + } + if (interval > 0L) { + if (frameCount < MAX_FRAMES) { + frames[frameCount] = (float) (interval / 1.0e6); + } + frameCount++; + frameSumNs += interval; + // The game's own timer for the frame that just ended: from its start to the blit, no waiting. + cpuSumNs += mc.getFrameTimeNs(); + } + if (now - phaseStart >= measureNs) { + Step measured = steps.get(step); + results[step] = result(measured.name); + if (measured.kind == Kind.TIMERS && gpuStart != null) { + gpuTable = GpuPassTimers.table(measured.name, gpuStart, GpuPassTimers.totals()); + } + if (step + 1 < steps.size()) { + begin(step + 1, now); + } else { + finish(mc); + } + } + } catch (RuntimeException e) { + Vesuvio.LOGGER.error("Vesuvio: the benchmark failed", e); + stop(Stop.ERROR); + } + } + + /** Any key or mouse button pressed (InputUiMixin, MouseUiMixin). */ + public static void input() { + if (steps != null) { + stop(Stop.INPUT); + } + } + + /** Stops a run, if one is going, and puts the player's settings back. */ + public static void stop(Stop reason) { + if (steps == null) { + return; + } + String at = stepName(); + List s = steps; + Result[] done = results.clone(); + Minecraft mc = Minecraft.getInstance(); + String setupText = reason == Stop.DONE ? "" : setup(mc); + restore(); + steps = null; + frames = null; + level = null; + if (reason == Stop.DONE) { + return; + } + Vesuvio.LOGGER.info("Vesuvio benchmark stopped at step {} ({}); settings put back", at, + reason.name().toLowerCase(Locale.ROOT)); + Component message = Component.translatable("vesuvio.benchmark.stopped", Component.translatable(reason.key())); + mc.gui.hud.setOverlayMessage(message, false); + mc.showDebugChat(message); + int measured = 0; + for (Result r : done) { + if (r != null) { + measured++; + } + } + if (measured > 0) { + try { + // Only a key or a move leaves the game where the player can read a results screen. + boolean show = reason == Stop.INPUT || reason == Stop.MOVED; + report(mc, done, s, setupText, Component.translatable("vesuvio.benchmark.partial", measured, s.size()), show); + } catch (RuntimeException e) { + Vesuvio.LOGGER.error("Vesuvio: could not report the benchmark's partial results", e); + } + } + } + + /** The action bar line: step, time left, and a reminder not to touch anything. */ + private static void countdown(Minecraft mc, long now) { + long perStep = SETTLE_NS + measureNs; + long elapsed = now - phaseStart; + long left = settling ? Math.max(0L, SETTLE_NS - elapsed) + measureNs : Math.max(0L, measureNs - elapsed); + left += (steps.size() - step - 1) * perStep; + long seconds = (left + 999_999_999L) / 1_000_000_000L; + mc.gui.hud.setOverlayMessage(Component.translatable("vesuvio.benchmark.progress", step + 1, steps.size(), + String.format(Locale.ROOT, "%d:%02d", seconds / 60, seconds % 60)), false); + } + + /** Sections waiting to be compiled (0 when unknown). */ + private static int compileQueue(Minecraft mc) { + SectionRenderDispatcher dispatcher = mc.levelRenderer.sectionRenderDispatcher(); + return dispatcher == null ? 0 : dispatcher.getCompileQueueSize(); + } + + private static @Nullable Stop interruption(Minecraft mc) { + LocalPlayer p = mc.player; + if (mc.level == null || mc.level != level || p == null) { + return Stop.LEFT_WORLD; + } + if (mc.gui.screen() != null || mc.gui.overlay() != null) { + return Stop.MENU; + } + if (mc.getWindow().isIconified()) { + return Stop.MINIMIZED; + } + if (step <= 0 && (step < 0 || settling && System.nanoTime() - phaseStart < COAST_NS)) { + startX = p.getX(); + startY = p.getY(); + startZ = p.getZ(); + startYRot = p.getYRot(); + startXRot = p.getXRot(); + return null; + } + double dx = p.getX() - startX; + double dy = p.getY() - startY; + double dz = p.getZ() - startZ; + if (dx * dx + dy * dy + dz * dz > MOVE_LIMIT * MOVE_LIMIT + || Math.abs(p.getYRot() - startYRot) > TURN_LIMIT || Math.abs(p.getXRot() - startXRot) > TURN_LIMIT) { + return Stop.MOVED; + } + return null; + } + + /** Puts the player's settings back, then applies step i's switches on top of them. */ + private static void begin(int i, long now) { + Step s = steps.get(i); + boolean[] target = saved.clone(); + for (int t = 0; t < toggles.size(); t++) { + VesuvioConfig.Toggle toggle = toggles.get(t); + if (toggle == VesuvioConfig.ENABLED) { + target[t] = s.kind != Kind.OFF; + } else if (toggle == s.flipped) { + target[t] = !saved[t]; + } + } + int[] numberTarget = savedNumbers.clone(); + for (int n = 0; n < numbers.size(); n++) { + if (numbers.get(n) == s.number) { + numberTarget[n] = s.numberValue; + } + } + apply(target, numberTarget); + setGpuTimers(s.kind == Kind.TIMERS); + step = i; + settling = true; + phaseStart = now; + } + + private static void restore() { + apply(saved, savedNumbers); + setGpuTimers(savedGpuTimers); + } + + /** gpuPassTimers on or off, telling listeners when it changed. Never saves. */ + private static void setGpuTimers(boolean on) { + if (GpuPassTimers.SETTING.get() != on) { + GpuPassTimers.SETTING.set(on); + VesuvioChanges.changed(GpuPassTimers.SETTING); + } + } + + /** Sets every toggle and number to its target, telling listeners about each one that changed. Never saves. */ + private static void apply(boolean[] target, int[] numberTarget) { + for (int t = 0; t < toggles.size(); t++) { + VesuvioConfig.Toggle toggle = toggles.get(t); + if (toggle.get() != target[t]) { + toggle.set(target[t]); + VesuvioChanges.changed(toggle); + } + } + for (int n = 0; n < numbers.size(); n++) { + VesuvioConfig.Number number = numbers.get(n); + if (number.get() != numberTarget[n]) { + number.set(numberTarget[n]); + VesuvioChanges.changed(number); + } + } + } + + private static Result result(String name) { + int n = frameCount; + double mean = n == 0 ? 0.0 : frameSumNs / n / 1.0e6; + double cpu = n == 0 ? 0.0 : cpuSumNs / n / 1.0e6; + int kept = Math.min(n, MAX_FRAMES); + double p95 = 0.0; + if (kept > 0) { + float[] sorted = Arrays.copyOf(frames, kept); + Arrays.sort(sorted); + p95 = sorted[Math.min(kept - 1, (int) Math.ceil(kept * 0.95) - 1)]; + } + int[] counters = new int[stats.size()]; + for (int i = 0; i < counters.length; i++) { + counters[i] = stats.get(i).read(); + } + long submitted = SubmitTimers.submits - submitsStart; + double encode = submitted <= 0 ? 0.0 : (SubmitTimers.encodeNsTotal - encodeStartNs) / (double) submitted / 1.0e6; + double wait = submitted <= 0 ? 0.0 : (SubmitTimers.waitNsTotal - waitStartNs) / (double) submitted / 1.0e6; + return new Result(name, n, mean, mean > 0.0 ? 1000.0 / mean : 0.0, p95, cpu, encode, wait, counters); + } + + private static void finish(Minecraft mc) { + Result[] done = results.clone(); + List s = steps; + String setupText = setup(mc); + stop(Stop.DONE); + report(mc, done, s, setupText, null, true); + } + + /** + * Logs the table of the measured steps and shows it on the results screen. {@code partial} is null for a + * finished run, else the line saying how many steps a stopped run measured (its other entries are null). + */ + private static void report(Minecraft mc, Result[] done, List s, String setupText, @Nullable Component partial, + boolean show) { + double onMs = mean(done, s, Kind.ON); + double offMs = mean(done, s, Kind.OFF); + StringBuilder table = new StringBuilder(); + table.append(String.format(Locale.ROOT, "%-3s%-28s%5s %6s %6s %7s | %6s %6s %6s%n", "#", "step", "fps", "frame", "p95", + "vs on", "cpu", "encode", "wait")); + for (int i = 0; i < done.length; i++) { + Result r = done[i]; + if (r == null) { + continue; + } + String vs = s.get(i).kind == Kind.ON ? "ref" + : onMs > 0 ? String.format(Locale.ROOT, "%+.2f", r.meanMs - onMs) : "?"; + table.append(String.format(Locale.ROOT, "%-3d%-28s%5d %6.2f %6.2f %7s | %6.2f %6.2f %6.2f%s%n", i, clip(r.name, 27), + Math.round(r.fps), r.meanMs, r.p95Ms, vs, r.cpuMs, r.encodeMs, r.waitMs, + r.frames < 30 ? " (few frames)" : "")); + } + if (!stats.isEmpty()) { + table.append(String.format(Locale.ROOT, "%nCounters at the end of each step:%n%-3s%-28s", "#", "step")); + for (VesuvioStats.Stat st : stats) { + table.append(String.format(Locale.ROOT, " %12s", clip(st.label(), 12))); + } + table.append(System.lineSeparator()); + for (int i = 0; i < done.length; i++) { + if (done[i] == null) { + continue; + } + table.append(String.format(Locale.ROOT, "%-3d%-28s", i, clip(done[i].name, 27))); + for (int c : done[i].counters) { + table.append(String.format(Locale.ROOT, " %12d", c)); + } + table.append(System.lineSeparator()); + } + } + if (gpuTable != null) { + table.append(System.lineSeparator()).append(gpuTable); + } + double offFps = offMs > 0 ? 1000.0 / offMs : 0.0; + double onFps = onMs > 0 ? 1000.0 / onMs : 0.0; + Vesuvio.LOGGER.info("Vesuvio benchmark {}: off {} fps ({} ms), on {} fps ({} ms). {}{}{}", + partial == null ? "done" : "partial results (" + partial.getString() + ")", Math.round(offFps), fmt(offMs), + Math.round(onFps), fmt(onMs), setupText, System.lineSeparator(), table); + if (partial == null) { + mc.showDebugChat(Component.translatable("vesuvio.benchmark.done", Math.round(offFps), Math.round(onFps), + String.format(Locale.ROOT, "%+.0f", offFps > 0 ? (onFps / offFps - 1.0) * 100.0 : 0.0))); + } else { + mc.showDebugChat(partial); + } + if (!show) { + return; + } + List rows = new ArrayList<>(); + for (int i = 0; i < done.length; i++) { + Result r = done[i]; + if (r == null) { + continue; + } + boolean ref = s.get(i).kind == Kind.ON || onMs <= 0; + rows.add(new VesuvioBenchmarkScreen.Row(i, r.name, Math.round(r.fps), r.meanMs, r.p95Ms, + ref ? Double.NaN : r.meanMs - onMs, s.get(i).kind == Kind.ON)); + } + VesuvioBenchmarkScreen screen = new VesuvioBenchmarkScreen(rows, onFps, offFps, partial); + if (partial == null) { + mc.gui.setScreen(screen); + } else { + pendingScreen = screen; + } + } + + /** Mean frame time of the steps of a kind (the two "on" or the two "off" steps). */ + private static double mean(Result[] done, List s, Kind kind) { + double sum = 0.0; + int n = 0; + for (int i = 0; i < done.length; i++) { + if (s.get(i).kind == kind && done[i] != null && done[i].frames > 0) { + sum += done[i].meanMs; + n++; + } + } + return n == 0 ? 0.0 : sum / n; + } + + /** What the numbers depend on besides Vesuvio, for the log. */ + private static String setup(Minecraft mc) { + var o = mc.options; + return String.format(Locale.ROOT, + "Window %dx%d, render distance %d, fps limit %s, vsync %s, Etna Shaders %s, %d live settings.", + mc.getWindow().getWidth(), mc.getWindow().getHeight(), o.renderDistance().get(), + o.framerateLimit().get() >= 260 ? "none" : o.framerateLimit().get().toString(), + o.enableVsync().get() ? "on" : "off", + FabricLoader.getInstance().isModLoaded("etna") ? "installed" : "not installed", + flipSteps()); + } + + /** Steps that change one live setting. */ + private static int flipSteps() { + List s = steps; + int n = 0; + if (s != null) { + for (Step step : s) { + if (step.kind == Kind.FLIP) { + n++; + } + } + } + return n; + } + + private static String fmt(double ms) { + return String.format(Locale.ROOT, "%.2f", ms); + } + + private static String clip(String s, int n) { + return s.length() <= n ? s : s.substring(0, n - 1) + "~"; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/VesuvioBenchmarkScreen.java b/src/main/java/it/cosimo/vesuvio/ui/VesuvioBenchmarkScreen.java new file mode 100644 index 0000000..a369139 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/VesuvioBenchmarkScreen.java @@ -0,0 +1,109 @@ +package it.cosimo.vesuvio.ui; + +import java.util.List; +import java.util.Locale; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.Nullable; + +/** + * The benchmark's summary when a run finishes, or is stopped by a key or a move after measuring some steps: one + * row per measured step. The full table is in latest.log. + */ +public final class VesuvioBenchmarkScreen extends Screen { + /** + * A step: its number in the run, fps, mean and 95th percentile frame time (ms), its mean minus the "on" steps' + * (NaN: it is one, or no "on" step was measured), and whether it is an "on" step. + */ + public record Row(int index, String name, long fps, double frameMs, double p95Ms, double vsOnMs, boolean ref) { + } + + private static final int TOP = 60; + private static final int LINE = 11; + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFC8C8C8; + + private final List rows; + private final double onFps; + private final double offFps; + /** For a stopped run, how many steps it measured; null when it finished. */ + private final @Nullable Component partial; + private int scroll; + + public VesuvioBenchmarkScreen(List rows, double onFps, double offFps, @Nullable Component partial) { + super(Component.translatable("vesuvio.benchmark.title")); + this.rows = List.copyOf(rows); + this.onFps = onFps; + this.offFps = offFps; + this.partial = partial; + } + + @Override + protected void init() { + this.addRenderableWidget(Button.builder(CommonComponents.GUI_DONE, b -> this.onClose()) + .bounds(this.width / 2 - 100, this.height - 28, 200, 20).build()); + } + + @Override + public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) { + int visible = Math.max(1, (this.height - TOP - 44) / LINE); + this.scroll = Math.max(0, Math.min(Math.max(0, this.rows.size() - visible), this.scroll - (int) Math.signum(scrollY))); + return true; + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + graphics.centeredText(this.font, this.title, this.width / 2, 10, TEXT); + boolean both = this.offFps > 0 && this.onFps > 0; + String gain = both ? String.format(Locale.ROOT, "%+.0f", (this.onFps / this.offFps - 1.0) * 100.0) : "?"; + graphics.centeredText(this.font, Component.translatable("vesuvio.benchmark.summary", fps(this.offFps), + fps(this.onFps), gain), this.width / 2, 24, TEXT); + if (this.partial != null) { + graphics.centeredText(this.font, this.partial, this.width / 2, 36, DIM); + } + + int tableWidth = Math.min(this.width - 16, 400); + int left = (this.width - tableWidth) / 2; + int nameRight = left + tableWidth - 4 * 52; + int[] cols = {nameRight + 52, nameRight + 104, nameRight + 156, nameRight + 208}; + int y = TOP - LINE - 2; + graphics.text(this.font, Component.translatable("vesuvio.benchmark.col.step"), left, y, DIM); + String[] heads = {"vesuvio.benchmark.col.fps", "vesuvio.benchmark.col.frame", "vesuvio.benchmark.col.p95", + "vesuvio.benchmark.col.vsOn"}; + for (int c = 0; c < 4; c++) { + Component h = Component.translatable(heads[c]); + graphics.text(this.font, h, cols[c] - this.font.width(h), y, DIM); + } + y = TOP; + int bottom = this.height - 44; + for (int i = this.scroll; i < this.rows.size() && y + LINE <= bottom; i++) { + Row r = this.rows.get(i); + graphics.text(this.font, this.font.plainSubstrByWidth(r.index + " " + r.name, nameRight - left), left, y, TEXT); + right(graphics, Long.toString(r.fps), cols[0], y, TEXT); + right(graphics, String.format(Locale.ROOT, "%.2f", r.frameMs), cols[1], y, TEXT); + right(graphics, String.format(Locale.ROOT, "%.2f", r.p95Ms), cols[2], y, TEXT); + if (Double.isNaN(r.vsOnMs)) { + right(graphics, r.ref ? "ref" : "?", cols[3], y, DIM); + } else { + // Positive: the step is slower than "on", so what it switched off was saving that much per frame. + right(graphics, String.format(Locale.ROOT, "%+.2f", r.vsOnMs), cols[3], y, TEXT); + } + y += LINE; + } + graphics.centeredText(this.font, Component.translatable("vesuvio.benchmark.footer"), this.width / 2, this.height - 40, + DIM); + } + + private static String fps(double fps) { + return fps > 0 ? Long.toString(Math.round(fps)) : "?"; + } + + private void right(GuiGraphicsExtractor graphics, String s, int x, int y, int color) { + graphics.text(this.font, s, x - this.font.width(s), y, color); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/VesuvioChanges.java b/src/main/java/it/cosimo/vesuvio/ui/VesuvioChanges.java new file mode 100644 index 0000000..6e4d410 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/VesuvioChanges.java @@ -0,0 +1,36 @@ +package it.cosimo.vesuvio.ui; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.Vesuvio; + +/** + * Tells optimizations that a setting was changed live: by the settings screen, the master key or the + * benchmark (which changes them and puts them back). The setting passed is the one that changed; + * {@link VesuvioConfig#ENABLED} means every optimization's on() may have changed. Listeners run on the + * render thread. For work that has to be redone when a switch flips, e.g. chunk meshes built with a + * culling rule: {@code VesuvioChanges.listen(s -> { if (s == ENABLED || s == MY_TOGGLE) rebuild(); });} + */ +public final class VesuvioChanges { + private static final List> LISTENERS = new CopyOnWriteArrayList<>(); + + private VesuvioChanges() { + } + + public static void listen(Consumer listener) { + LISTENERS.add(listener); + } + + static void changed(VesuvioConfig.Setting setting) { + for (Consumer listener : LISTENERS) { + try { + listener.accept(setting); + } catch (RuntimeException e) { + Vesuvio.LOGGER.error("Vesuvio: a listener failed after {} changed", setting.key, e); + } + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/VesuvioDebugEntry.java b/src/main/java/it/cosimo/vesuvio/ui/VesuvioDebugEntry.java new file mode 100644 index 0000000..4db0e32 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/VesuvioDebugEntry.java @@ -0,0 +1,49 @@ +package it.cosimo.vesuvio.ui; + +import java.util.ArrayList; +import java.util.List; + +import it.cosimo.vesuvio.VesuvioConfig; +import net.minecraft.client.gui.components.debug.DebugGroup; +import net.minecraft.client.gui.components.debug.DebugScreenDisplayer; +import net.minecraft.client.gui.components.debug.DebugScreenEntry; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.chunk.LevelChunk; +import org.jspecify.annotations.Nullable; + +/** + * Vesuvio's lines on the F3 screen: whether it is on, and the live counters (a few per line) ({@link VesuvioStats}). + * Registered as the debug entry "vesuvio:vesuvio" (DebugScreenEntriesMixin), shown with F3 by + * default (DebugScreenEntryListMixin) and listed in vanilla's debug options screen, where it can be set to + * always / with F3 / never like any other entry. + */ +public final class VesuvioDebugEntry implements DebugScreenEntry { + public static final Identifier ID = Identifier.fromNamespaceAndPath("vesuvio", "vesuvio"); + private static final DebugGroup GROUP = DebugGroup.Builder.titleless().build(); + private static final int STATS_PER_LINE = 3; + + @Override + public void display(DebugScreenDisplayer displayer, @Nullable Level serverOrClientLevel, @Nullable LevelChunk clientChunk, + @Nullable LevelChunk serverChunk) { + List lines = new ArrayList<>(6); + String state = VesuvioConfig.ENABLED.get() ? "on" : "off"; + if (VesuvioBenchmark.running()) { + state += " (benchmark: " + VesuvioBenchmark.stepName() + ")"; + } + lines.add("Vesuvio " + state); + lines.add("Memory: " + it.cosimo.vesuvio.memory.MemoryAdvisor.status().getString()); + if (VesuvioConfig.ENABLED.get()) { + // A few counters per line, so a long list stays on screen. + lines.addAll(VesuvioStats.lines(STATS_PER_LINE)); + // GPU time per pass, while gpuPassTimers is on (labels change, so these are whole lines, not counters). + lines.addAll(it.cosimo.vesuvio.frame.GpuPassTimers.debugLines()); + } + displayer.addToGroup(GROUP, lines); + } + + @Override + public boolean isAllowed(boolean reducedDebugInfo) { + return true; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/VesuvioKey.java b/src/main/java/it/cosimo/vesuvio/ui/VesuvioKey.java new file mode 100644 index 0000000..c9bb101 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/VesuvioKey.java @@ -0,0 +1,19 @@ +package it.cosimo.vesuvio.ui; + +import com.mojang.blaze3d.platform.InputConstants; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; + +/** + * The master switch's key, F8 by default. It is a normal vanilla key mapping in its own "Vesuvio" + * category, so it can be changed in Options > Controls > Key Binds and is saved in options.txt + * ("key_key.vesuvio.toggle"); OptionsKeyMixin adds it to the list vanilla loads, saves and shows. + */ +public final class VesuvioKey { + public static final KeyMapping.Category CATEGORY = + KeyMapping.Category.register(Identifier.fromNamespaceAndPath("vesuvio", "main")); + public static final KeyMapping TOGGLE = new KeyMapping("key.vesuvio.toggle", InputConstants.KEY_F8, CATEGORY); + + private VesuvioKey() { + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/VesuvioSettingsScreen.java b/src/main/java/it/cosimo/vesuvio/ui/VesuvioSettingsScreen.java new file mode 100644 index 0000000..c5a2232 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/VesuvioSettingsScreen.java @@ -0,0 +1,457 @@ +package it.cosimo.vesuvio.ui; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.memory.MemoryAdvisor; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Options; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractSliderButton; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.ContainerObjectSelectionList; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.narration.NarratableEntry; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.locale.Language; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import org.jspecify.annotations.Nullable; + +/** + * Vesuvio's settings, laid out like Sodium's: pages on the left, the page's options in a scrolling list, + * and a box on the right that explains the option under the mouse (or shows the live counters). It lists + * whatever settings exist ({@link VesuvioConfig#all()}), grouped by their page, so a new optimization's + * settings appear without any change here. Every change applies at once and is saved to + * config/vesuvio.properties; options marked * take effect after a restart. + */ +public final class VesuvioSettingsScreen extends Screen { + /** Pages in this order first; any other page a setting names follows, in the order first seen. */ + private static final List PAGE_ORDER = List.of("general", "rendering", "entities", "memory", "frame", "world"); + private static final int SIDEBAR = 120; + private static final int TOP = 32; + private static final int ROW = 24; + private static final int FOOTER = 36; + private static final int PANEL_MIN = 120; + private static final int TEXT = 0xFFFFFFFF; + private static final int DIM = 0xFFC8C8C8; + private static final int GOLD = 0xFFFFAA00; + private static final int RED = 0xFFFF5555; + /** + * Values of the restart-only settings as the game started with them (first time this screen saw them: + * nothing else changes those), to say when a restart is due. + */ + private static final Map AT_START = new HashMap<>(); + + private final Screen lastScreen; + private final String page; + private @Nullable OptionList list; + private int panelLeft; + private boolean panel; + + public VesuvioSettingsScreen(Screen lastScreen) { + this(lastScreen, "general"); + } + + private VesuvioSettingsScreen(Screen lastScreen, String page) { + super(Component.translatable("vesuvio.screen.title")); + this.lastScreen = lastScreen; + this.page = page; + } + + @Override + protected void init() { + // A menu stops a benchmark anyway; do it before reading any value, so the player's own ones show. + VesuvioBenchmark.stop(VesuvioBenchmark.Stop.MENU); + List all = VesuvioConfig.all(); + for (VesuvioConfig.Setting s : all) { + if (s.needsRestart) { + AT_START.putIfAbsent(s.key, value(s)); + } + } + + int y = TOP; + for (String p : pages(all)) { + Button tab = Button.builder(pageName(p), b -> this.open(p)).bounds(8, y, SIDEBAR - 16, 20).build(); + tab.active = !p.equals(this.page); + this.addRenderableWidget(tab); + y += 22; + } + this.addRenderableWidget(Button.builder(Component.translatable("vesuvio.screen.reset"), b -> this.resetPage()) + .bounds(8, this.height - 28, SIDEBAR - 16, 20) + .tooltip(Component.translatable("vesuvio.screen.reset.tooltip")) + .build()); + this.addRenderableWidget(Button.builder(CommonComponents.GUI_DONE, b -> this.onClose()) + .bounds(this.width - 108, this.height - 28, 100, 20).build()); + + int listLeft = SIDEBAR + 4; + int room = this.width - listLeft - 8; + int listWidth; + if (room >= 200 + PANEL_MIN + 8) { + listWidth = Math.min(320, Math.max(200, room * 55 / 100)); + this.panel = true; + } else { + listWidth = room; + this.panel = false; + } + this.panelLeft = listLeft + listWidth + 8; + OptionList options = new OptionList(this.minecraft, listWidth, this.height - TOP - FOOTER, TOP); + options.updateSizeAndPosition(listWidth, this.height - TOP - FOOTER, listLeft, TOP); + if (this.page.equals("memory")) { + options.add(this.memoryRow()); + } + for (VesuvioConfig.Setting s : all) { + if (s.page.equals(this.page)) { + options.add(this.row(s)); + } + } + if (this.page.equals("general")) { + options.add(this.benchmarkRow()); + } + this.list = this.addRenderableWidget(options); + } + + /** Pages to show: the known ones always, then any other page some setting is on. */ + private static List pages(List all) { + Set pages = new LinkedHashSet<>(PAGE_ORDER); + for (VesuvioConfig.Setting s : all) { + pages.add(s.page); + } + List out = new ArrayList<>(); + for (String p : pages) { + // "world" and unknown pages only once something is on them. + if (PAGE_ORDER.indexOf(p) >= 0 && PAGE_ORDER.indexOf(p) < 5 || all.stream().anyMatch(s -> s.page.equals(p))) { + out.add(p); + } + } + return out; + } + + private Row row(VesuvioConfig.Setting s) { + Component name = name(s); + Component label = s.needsRestart + ? Component.empty().append(name).append(Component.literal(" *").withStyle(ChatFormatting.GOLD)) + : name; + AbstractWidget widget; + if (s instanceof VesuvioConfig.Toggle t) { + Button button = Button.builder(toggleLabel(label, t.get()), b -> { + t.set(!t.get()); + b.setMessage(toggleLabel(label, t.get())); + VesuvioConfig.save(); + VesuvioChanges.changed(t); + }).size(200, 20).build(); + widget = button; + } else { + widget = new Slider(label, (VesuvioConfig.Number) s); + } + Row row = new Row(widget, s, description(s)); + if (!this.panel) { + widget.setTooltip(Tooltip.create(row.text())); + } + return row; + } + + private Row benchmarkRow() { + boolean inWorld = this.minecraft.level != null && this.minecraft.player != null; + Button button = Button.builder(Component.translatable("vesuvio.benchmark.button"), b -> { + VesuvioConfig.save(); + this.minecraft.gui.setScreen(null); + VesuvioBenchmark.start(); + }).size(200, 20).build(); + button.active = inWorld; + Row row = new Row(button, null, new Description(Component.translatable("vesuvio.benchmark.button"), + Component.translatable(inWorld ? "vesuvio.benchmark.tooltip" : "vesuvio.benchmark.noWorld"), null)); + if (!this.panel) { + button.setTooltip(Tooltip.create(row.text())); + } + return row; + } + + /** The memory check: its verdict on the button, the advice and the numbers in the box; a click says it in chat. */ + private Row memoryRow() { + Button button = Button.builder(MemoryAdvisor.status(), b -> { + this.minecraft.gui.hud.getChat().addClientSystemMessage( + Component.translatable("vesuvio.memory.chat", MemoryAdvisor.advice())); + b.setMessage(MemoryAdvisor.status()); + }).size(200, 20).build(); + Row row = new Row(button, null, new Description(Component.translatable("vesuvio.memory.title").withStyle(ChatFormatting.BOLD), + Component.empty().append(MemoryAdvisor.advice()).append("\n\n").append(Component.translatable("vesuvio.memory.howTo")), + Component.literal(MemoryAdvisor.facts()))); + if (!this.panel) { + button.setTooltip(Tooltip.create(row.text())); + } + return row; + } + + private static Component toggleLabel(Component name, boolean on) { + return Options.genericValueLabel(name, on ? CommonComponents.OPTION_ON : CommonComponents.OPTION_OFF); + } + + private void resetPage() { + for (VesuvioConfig.Setting s : VesuvioConfig.all()) { + if (s.page.equals(this.page)) { + String before = value(s); + s.reset(); + if (!before.equals(value(s))) { + VesuvioChanges.changed(s); + } + } + } + VesuvioConfig.save(); + this.open(this.page); + } + + private void open(String target) { + this.minecraft.gui.setScreen(new VesuvioSettingsScreen(this.lastScreen, target)); + } + + @Override + public void removed() { + // Sliders save when let go; this catches keyboard changes. + VesuvioConfig.save(); + } + + @Override + public void onClose() { + this.minecraft.gui.setScreen(this.lastScreen); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + graphics.fill(0, 0, SIDEBAR, this.height, 0x70000000); + graphics.centeredText(this.font, this.title, SIDEBAR / 2, 12, TEXT); + super.extractRenderState(graphics, mouseX, mouseY, a); + + int x = SIDEBAR + 8; + graphics.text(this.font, pageName(this.page), x, 12, TEXT); + int noteX = x + this.font.width(pageName(this.page)) + 12; + if (!VesuvioConfig.ENABLED.get()) { + graphics.text(this.font, Component.translatable("vesuvio.screen.masterOff"), noteX, 12, GOLD); + } else if (restartDue()) { + graphics.text(this.font, Component.translatable("vesuvio.screen.restartDue"), noteX, 12, RED); + } + OptionList options = this.list; + if (options != null && options.children().isEmpty()) { + graphics.text(this.font, Component.translatable("vesuvio.screen.emptyPage"), x, TOP + 8, DIM); + } + if (options != null && options.children().stream().anyMatch(r -> r.setting != null && r.setting.needsRestart)) { + graphics.text(this.font, Component.translatable("vesuvio.screen.restartLegend"), x, this.height - 22, GOLD); + } + if (this.panel && options != null) { + this.extractPanel(graphics, options); + } + } + + /** The description box: the option under the mouse (or with keyboard focus), else the live counters. */ + private void extractPanel(GuiGraphicsExtractor graphics, OptionList options) { + int left = this.panelLeft; + int right = this.width - 8; + int width = right - left - 12; + int bottom = this.height - FOOTER; + graphics.fill(left, TOP, right, bottom, 0x90000000); + int y = TOP + 6; + Row row = options.hoveredRow(); + if (row == null) { + row = options.getFocused(); + } + if (row != null) { + Description d = row.description; + y = graphics.textWithWordWrap(this.font, d.title, left + 6, y, width, TEXT) + 4; + y = graphics.textWithWordWrap(this.font, d.body, left + 6, y, width, DIM) + 4; + if (d.note != null) { + graphics.textWithWordWrap(this.font, d.note, left + 6, y, width, GOLD); + } + return; + } + y = graphics.textWithWordWrap(this.font, Component.translatable("vesuvio.screen.hint"), left + 6, y, width, DIM) + 8; + List stats = VesuvioStats.all(); + if (stats.isEmpty()) { + return; + } + graphics.text(this.font, Component.translatable("vesuvio.screen.stats"), left + 6, y, TEXT); + y += 12; + boolean on = VesuvioConfig.ENABLED.get(); + for (VesuvioStats.Stat s : stats) { + if (y > bottom - 10) { + break; + } + String line = s.label() + ": " + (on ? Integer.toString(s.read()) : "-"); + graphics.text(this.font, this.font.plainSubstrByWidth(line, width), left + 6, y, DIM); + y += 10; + } + } + + private static boolean restartDue() { + for (VesuvioConfig.Setting s : VesuvioConfig.all()) { + String start = AT_START.get(s.key); + if (s.needsRestart && start != null && !start.equals(value(s))) { + return true; + } + } + return false; + } + + private static String value(VesuvioConfig.Setting s) { + if (s instanceof VesuvioConfig.Toggle t) { + return Boolean.toString(t.get()); + } + return Integer.toString(((VesuvioConfig.Number) s).get()); + } + + private static Component pageName(String page) { + return translatedOr("vesuvio.page." + page, Character.toUpperCase(page.charAt(0)) + page.substring(1)); + } + + private static Component name(VesuvioConfig.Setting s) { + return translatedOr(s.nameKey(), s.key); + } + + /** A translation, or the fallback text when the language file has no such key (a setting added without one). */ + private static Component translatedOr(String key, String fallback) { + return Language.getInstance().has(key) ? Component.translatable(key) : Component.literal(fallback); + } + + private static Description description(VesuvioConfig.Setting s) { + Component body = Language.getInstance().has(s.tooltipKey()) + ? Component.translatable(s.tooltipKey()) + : Component.translatable("vesuvio.screen.noTooltip"); + MutableComponent note = Component.empty(); + if (s instanceof VesuvioConfig.Toggle t) { + note.append(Component.translatable("vesuvio.screen.default", + t.defaultValue ? CommonComponents.OPTION_ON : CommonComponents.OPTION_OFF)); + } else { + VesuvioConfig.Number n = (VesuvioConfig.Number) s; + note.append(Component.translatable("vesuvio.screen.defaultRange", n.defaultValue, n.min, n.max)); + } + if (s.needsRestart) { + note.append(" ").append(Component.translatable("vesuvio.screen.needsRestart")); + } + if (s == it.cosimo.vesuvio.frame.GpuPassTimers.SETTING && it.cosimo.vesuvio.frame.GpuPassTimers.unsupported()) { + note.append(" ").append(Component.translatable("vesuvio.option.gpuPassTimers.unsupported")); + } + return new Description(name(s).copy().withStyle(ChatFormatting.BOLD), body, note); + } + + private record Description(Component title, Component body, @Nullable Component note) { + } + + /** The scrolling column of options. */ + static final class OptionList extends ContainerObjectSelectionList { + OptionList(Minecraft minecraft, int width, int height, int y) { + super(minecraft, width, height, y, ROW); + this.centerListVertically = false; + } + + void add(Row row) { + this.addEntry(row); + } + + + @Override + public int getRowWidth() { + return this.getWidth() - 16; + } + + @Override + protected int scrollBarX() { + return this.getRight() - 6; + } + + @Nullable Row hoveredRow() { + return this.getHovered(); + } + } + + /** One option: its button or slider, filling the row. */ + static final class Row extends ContainerObjectSelectionList.Entry { + final AbstractWidget widget; + final VesuvioConfig.@Nullable Setting setting; + final Description description; + + Row(AbstractWidget widget, VesuvioConfig.@Nullable Setting setting, Description description) { + this.widget = widget; + this.setting = setting; + this.description = description; + } + + /** The whole description as one text, for a tooltip when there is no room for the box. */ + Component text() { + MutableComponent t = Component.empty().append(this.description.body); + if (this.description.note != null) { + t.append("\n\n").append(this.description.note); + } + return t; + } + + @Override + public void extractContent(GuiGraphicsExtractor graphics, int mouseX, int mouseY, boolean hovered, float a) { + this.widget.setPosition(this.getContentX(), this.getContentY()); + this.widget.setWidth(this.getContentWidth()); + this.widget.extractRenderState(graphics, mouseX, mouseY, a); + } + + @Override + public List children() { + return List.of(this.widget); + } + + @Override + public List narratables() { + return List.of(this.widget); + } + } + + /** A whole-number setting. Applies live while dragged, saves when let go. */ + private static final class Slider extends AbstractSliderButton { + private final Component name; + private final VesuvioConfig.Number setting; + + Slider(Component name, VesuvioConfig.Number setting) { + super(0, 0, 200, 20, Component.empty(), position(setting, setting.get())); + this.name = name; + this.setting = setting; + this.updateMessage(); + } + + private static double position(VesuvioConfig.Number s, int v) { + return s.max == s.min ? 0.0 : (v - s.min) / (double) (s.max - s.min); + } + + private int current() { + return (int) Math.round(this.setting.min + this.value * (this.setting.max - this.setting.min)); + } + + @Override + protected void updateMessage() { + this.setMessage(Options.genericValueLabel(this.name, this.current())); + } + + @Override + protected void applyValue() { + int v = this.current(); + if (v != this.setting.get()) { + this.setting.set(v); + VesuvioChanges.changed(this.setting); + } + } + + @Override + public void onRelease(MouseButtonEvent event) { + super.onRelease(event); + // Snap the handle to the whole number it stands for. + this.value = position(this.setting, this.setting.get()); + this.updateMessage(); + VesuvioConfig.save(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/VesuvioStats.java b/src/main/java/it/cosimo/vesuvio/ui/VesuvioStats.java new file mode 100644 index 0000000..a8684c4 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/VesuvioStats.java @@ -0,0 +1,85 @@ +package it.cosimo.vesuvio.ui; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.IntSupplier; + +/** + * Live counters other optimizations can show without touching the UI: the F3 screen ("vesuvio" entry, + * a few counters per line), the settings screen's description box while no option is under the mouse, latest.log every + * 5 s with the statsLog setting on, and the end of each benchmark step. + * + *

VesuvioStats.register("leaves culled", () -> LeafCulling.culledLastFrame);
+ * + * The supplier is read on the render thread a few times a second at most (never per frame unless F3 is + * open), so a plain static int written by the hook is enough; it must not allocate or block. + */ +public final class VesuvioStats { + /** A counter: its label (short, lower case, English: it goes in the log too) and how to read it. */ + public record Stat(String label, IntSupplier value) { + /** The value now, or -1 if reading it failed (so a broken counter never breaks F3 or the log). */ + public int read() { + try { + return this.value.getAsInt(); + } catch (RuntimeException e) { + return -1; + } + } + } + + private static final List STATS = new ArrayList<>(); + /** Copy read by the render thread; replaced, never changed, so reading needs no lock. */ + private static volatile List snapshot = List.of(); + + private VesuvioStats() { + } + + /** Adds a counter. Registering the same label again replaces the old one. Any thread, any time. */ + public static synchronized void register(String label, IntSupplier value) { + STATS.removeIf(s -> s.label().equals(label)); + STATS.add(new Stat(label, value)); + snapshot = List.copyOf(STATS); + } + + /** Every counter, in the order they were registered. */ + public static List all() { + return snapshot; + } + + /** The same as {@link #line()}, cut into lines of at most {@code perLine} counters (F3). Empty when none. */ + public static List lines(int perLine) { + List stats = snapshot; + List out = new ArrayList<>((stats.size() + perLine - 1) / Math.max(1, perLine)); + StringBuilder b = new StringBuilder(); + int onLine = 0; + for (Stat s : stats) { + if (onLine == perLine) { + out.add(b.toString()); + b.setLength(0); + onLine = 0; + } + if (onLine > 0) { + b.append(", "); + } + b.append(s.label()).append(' ').append(s.read()); + onLine++; + } + if (onLine > 0) { + out.add(b.toString()); + } + return out; + } + + /** "label value, label value, ..." on one line; empty when no counter is registered. */ + public static String line() { + List stats = snapshot; + StringBuilder b = new StringBuilder(); + for (Stat s : stats) { + if (b.length() > 0) { + b.append(", "); + } + b.append(s.label()).append(' ').append(s.read()); + } + return b.toString(); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/VesuvioUi.java b/src/main/java/it/cosimo/vesuvio/ui/VesuvioUi.java new file mode 100644 index 0000000..f9cab16 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/VesuvioUi.java @@ -0,0 +1,66 @@ +package it.cosimo.vesuvio.ui; + +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.Vesuvio; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; + +/** + * The parts of Vesuvio the player touches: the master key, the stats log, and the settings the + * settings screen and benchmark use. The screen itself is {@link VesuvioSettingsScreen}. + */ +public final class VesuvioUi { + /** Writes the live counters ({@link VesuvioStats}) to latest.log every 5 s. */ + public static final VesuvioConfig.Toggle STATS_LOG = VesuvioConfig.toggle("general", "statsLog", false, false); + /** How long each benchmark step measures, after at least 3 s to settle. */ + public static final VesuvioConfig.Number BENCHMARK_SECONDS = VesuvioConfig.number("general", "benchmarkSeconds", 5, 60, 10, + false); + + private static final long STATS_LOG_NS = 5_000_000_000L; + private static long lastStatsLog; + + private VesuvioUi() { + } + + /** Called from Vesuvio.onInitializeClient: makes this package's settings exist early. */ + public static void init() { + // The static settings above are made by loading this class. + } + + /** Client tick, no menu open (Minecraft.handleKeybinds): the master key. */ + public static void handleKeys(Minecraft mc) { + while (VesuvioKey.TOGGLE.consumeClick()) { + toggleMaster(mc); + } + } + + /** Flips the master switch, saves it and says so above the hotbar. */ + public static void toggleMaster(Minecraft mc) { + // A key press has stopped a running benchmark already (InputUiMixin); this is for any other way in. + VesuvioBenchmark.stop(VesuvioBenchmark.Stop.OTHER); + boolean on = !VesuvioConfig.ENABLED.get(); + VesuvioConfig.ENABLED.set(on); + VesuvioConfig.save(); + VesuvioChanges.changed(VesuvioConfig.ENABLED); + mc.gui.hud.setOverlayMessage(Component.translatable(on ? "vesuvio.toggle.on" : "vesuvio.toggle.off"), false); + Vesuvio.LOGGER.info("Vesuvio switched {} with the key", on ? "on" : "off"); + } + + /** End of every client tick: the memory check and the stats log. */ + public static void afterTick(Minecraft mc) { + it.cosimo.vesuvio.memory.MemoryAdvisor.tick(mc); + it.cosimo.vesuvio.frame.PipelineDiskCache.maybeSave(); + if (!STATS_LOG.on()) { + return; + } + long now = System.nanoTime(); + if (now - lastStatsLog < STATS_LOG_NS) { + return; + } + lastStatsLog = now; + String stats = VesuvioStats.line(); + Vesuvio.LOGGER.info("Vesuvio stats: {} fps, frame {} ms (CPU){}", mc.getFps(), + String.format(java.util.Locale.ROOT, "%.2f", mc.getFrameTimeNs() / 1.0e6), + stats.isEmpty() ? "" : ", " + stats); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/mixin/DebugScreenEntriesMixin.java b/src/main/java/it/cosimo/vesuvio/ui/mixin/DebugScreenEntriesMixin.java new file mode 100644 index 0000000..de6427f --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/mixin/DebugScreenEntriesMixin.java @@ -0,0 +1,26 @@ +package it.cosimo.vesuvio.ui.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.ui.VesuvioDebugEntry; +import net.minecraft.client.gui.components.debug.DebugScreenEntries; +import net.minecraft.client.gui.components.debug.DebugScreenEntry; +import net.minecraft.resources.Identifier; + +/** Registers Vesuvio's F3 entry with vanilla's, once, when the entry table is built. */ +@Mixin(DebugScreenEntries.class) +public abstract class DebugScreenEntriesMixin { + @Shadow + private static Identifier register(Identifier identifier, DebugScreenEntry entry) { + throw new AssertionError(); + } + + @Inject(method = "", at = @At("TAIL")) + private static void vesuvio$register(CallbackInfo ci) { + register(VesuvioDebugEntry.ID, new VesuvioDebugEntry()); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/mixin/DebugScreenEntryListMixin.java b/src/main/java/it/cosimo/vesuvio/ui/mixin/DebugScreenEntryListMixin.java new file mode 100644 index 0000000..926918c --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/mixin/DebugScreenEntryListMixin.java @@ -0,0 +1,32 @@ +package it.cosimo.vesuvio.ui.mixin; + +import java.util.Map; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.ui.VesuvioDebugEntry; +import net.minecraft.client.gui.components.debug.DebugScreenEntryList; +import net.minecraft.client.gui.components.debug.DebugScreenEntryStatus; +import net.minecraft.client.gui.components.debug.DebugScreenProfile; +import net.minecraft.resources.Identifier; + +/** + * Shows Vesuvio's F3 lines with the overlay in vanilla's built-in debug profiles (which only list + * vanilla's entries). A custom profile keeps whatever the player chose for it. + */ +@Mixin(DebugScreenEntryList.class) +public abstract class DebugScreenEntryListMixin { + @Shadow + @Final + private Map allStatuses; + + @Inject(method = "resetToProfile(Lnet/minecraft/client/gui/components/debug/DebugScreenProfile;)V", at = @At("TAIL")) + private void vesuvio$showWithF3(DebugScreenProfile profile, CallbackInfo ci) { + this.allStatuses.putIfAbsent(VesuvioDebugEntry.ID, DebugScreenEntryStatus.IN_OVERLAY); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/mixin/InputUiMixin.java b/src/main/java/it/cosimo/vesuvio/ui/mixin/InputUiMixin.java new file mode 100644 index 0000000..29746a4 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/mixin/InputUiMixin.java @@ -0,0 +1,25 @@ +package it.cosimo.vesuvio.ui.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.blaze3d.platform.InputConstants; +import it.cosimo.vesuvio.ui.VesuvioBenchmark; +import net.minecraft.client.KeyboardHandler; +import net.minecraft.client.input.KeyEvent; + +/** + * Any key pressed stops a running benchmark, before the key does anything else (the master key, Escape's + * pause menu), so the key acts on the player's own settings. + */ +@Mixin(KeyboardHandler.class) +public abstract class InputUiMixin { + @Inject(method = "keyPress(JILnet/minecraft/client/input/KeyEvent;)V", at = @At("HEAD")) + private void vesuvio$key(long handle, int action, KeyEvent event, CallbackInfo ci) { + if (action == InputConstants.PRESS) { + VesuvioBenchmark.input(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/mixin/MinecraftUiMixin.java b/src/main/java/it/cosimo/vesuvio/ui/mixin/MinecraftUiMixin.java new file mode 100644 index 0000000..59d9fa5 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/mixin/MinecraftUiMixin.java @@ -0,0 +1,33 @@ +package it.cosimo.vesuvio.ui.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.ui.VesuvioBenchmark; +import it.cosimo.vesuvio.ui.VesuvioUi; +import net.minecraft.client.Minecraft; + +/** + * The benchmark's frame clock (start of each pass of the game loop, render thread), the master key + * (with the other key bindings, only while no menu is open) and the stats log (end of each client tick). + * Each is one static check when nothing is going on. + */ +@Mixin(Minecraft.class) +public abstract class MinecraftUiMixin { + @Inject(method = "runTick(Z)V", at = @At("HEAD")) + private void vesuvio$frame(boolean advanceGameTime, CallbackInfo ci) { + VesuvioBenchmark.frame(); + } + + @Inject(method = "handleKeybinds()V", at = @At("HEAD")) + private void vesuvio$keys(CallbackInfo ci) { + VesuvioUi.handleKeys((Minecraft) (Object) this); + } + + @Inject(method = "tick()V", at = @At("RETURN")) + private void vesuvio$afterTick(CallbackInfo ci) { + VesuvioUi.afterTick((Minecraft) (Object) this); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/mixin/MouseUiMixin.java b/src/main/java/it/cosimo/vesuvio/ui/mixin/MouseUiMixin.java new file mode 100644 index 0000000..d76c083 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/mixin/MouseUiMixin.java @@ -0,0 +1,22 @@ +package it.cosimo.vesuvio.ui.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.blaze3d.platform.InputConstants; +import it.cosimo.vesuvio.ui.VesuvioBenchmark; +import net.minecraft.client.MouseHandler; +import net.minecraft.client.input.MouseButtonInfo; + +/** A mouse button pressed stops a running benchmark, like a key. */ +@Mixin(MouseHandler.class) +public abstract class MouseUiMixin { + @Inject(method = "onButton(JLnet/minecraft/client/input/MouseButtonInfo;I)V", at = @At("HEAD")) + private void vesuvio$button(long handle, MouseButtonInfo button, int action, CallbackInfo ci) { + if (action == InputConstants.PRESS) { + VesuvioBenchmark.input(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/mixin/OptionsKeyMixin.java b/src/main/java/it/cosimo/vesuvio/ui/mixin/OptionsKeyMixin.java new file mode 100644 index 0000000..fefd7e8 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/mixin/OptionsKeyMixin.java @@ -0,0 +1,39 @@ +package it.cosimo.vesuvio.ui.mixin; + +import java.util.Arrays; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.ui.VesuvioKey; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Options; + +/** + * Adds Vesuvio's key to the list of key mappings vanilla loads from and saves to options.txt and shows + * in Controls > Key Binds. Options.load runs at the end of Options' constructor, after the list is built. + */ +@Mixin(Options.class) +public abstract class OptionsKeyMixin { + @Shadow + @Final + @Mutable + public KeyMapping[] keyMappings; + + @Inject(method = "load()V", at = @At("HEAD")) + private void vesuvio$addKey(CallbackInfo ci) { + for (KeyMapping k : this.keyMappings) { + if (k == VesuvioKey.TOGGLE) { + return; + } + } + KeyMapping[] more = Arrays.copyOf(this.keyMappings, this.keyMappings.length + 1); + more[more.length - 1] = VesuvioKey.TOGGLE; + this.keyMappings = more; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/ui/mixin/VideoSettingsScreenMixin.java b/src/main/java/it/cosimo/vesuvio/ui/mixin/VideoSettingsScreenMixin.java new file mode 100644 index 0000000..8b9a2a6 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/ui/mixin/VideoSettingsScreenMixin.java @@ -0,0 +1,32 @@ +package it.cosimo.vesuvio.ui.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.ui.VesuvioSettingsScreen; +import net.minecraft.client.Options; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.options.OptionsSubScreen; +import net.minecraft.client.gui.screens.options.VideoSettingsScreen; +import net.minecraft.network.chat.Component; + +/** Puts an "Vesuvio..." button at the top of Video Settings' option list (before the Display header). */ +@Mixin(VideoSettingsScreen.class) +public abstract class VideoSettingsScreenMixin extends OptionsSubScreen { + protected VideoSettingsScreenMixin(Screen lastScreen, Options options, Component title) { + super(lastScreen, options, title); + } + + @Inject(method = "addOptions()V", at = @At("HEAD")) + private void vesuvio$addButton(CallbackInfo ci) { + if (this.list == null) { + return; + } + Screen videoSettings = this; + this.list.addBig(Button.builder(Component.translatable("vesuvio.screen.open"), + b -> this.minecraft.gui.setScreen(new VesuvioSettingsScreen(videoSettings))).build()); + } +} diff --git a/src/main/java/it/cosimo/vesuvio/vertex/CompactVertices.java b/src/main/java/it/cosimo/vesuvio/vertex/CompactVertices.java new file mode 100644 index 0000000..9141806 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/vertex/CompactVertices.java @@ -0,0 +1,216 @@ +package it.cosimo.vesuvio.vertex; + +import java.nio.ByteBuffer; + +import org.lwjgl.system.MemoryUtil; + +import com.mojang.blaze3d.vertex.CompactVectorArray; +import com.mojang.blaze3d.vertex.VertexFormat; +import com.mojang.blaze3d.vertex.VertexFormatElement; +import com.mojang.renderpearl.api.GpuFormat; + +import it.cosimo.vesuvio.Vesuvio; +import it.cosimo.vesuvio.VesuvioConfig; + +/** + * Setting compactTerrainVertices (World page, off, restart): chunk meshes in a 20-byte vertex instead of vanilla's + * 28-byte BLOCK vertex, so terrain takes about 29% less graphics memory, upload bandwidth and vertex fetch (which + * matters most with Etna, whose shadow pass reads every caster vertex once more). + * + *

Vanilla's BLOCK vertex: Position RGB32_FLOAT (12), Color RGBA8_UNORM (4), UV0 RG32_FLOAT (8), UV2 RG16_SINT (4). + * The compact one: Position RGBA16_FLOAT (8, the fourth half unused), Color and UV2 unchanged, UV0 RG16_UNORM (4). + * The GPU's vertex fetch turns half floats and 16-bit unorms into the same {@code vec3 Position} and + * {@code vec2 UV0} the shaders read, so no shader changes: vanilla's terrain shader and Etna's both work as they are. + * Both formats are in Vulkan's list of formats every device must support for vertex buffers (no 3-component 16-bit + * format, which AMD lacks); on Metal they are Half4 and UShort2Normalized. + * + *

Only the chunk pipelines change: the format is swapped in vanilla's terrain snippet while RenderPipelines is + * being set up (RenderPipelinesCompactMixin), so every terrain pipeline (solid, cutout, translucent, wireframe, + * improved fog, multidraw, improved transparency) and every copy another mod makes of one (Etna's shadow and + * shadowed terrain pipelines copy {@code getVertexFormatBindings()}) uses it from the start, and the section + * builders, the GPU buffers and the draws all take their stride from {@code ChunkSectionLayer.vertexFormat()}. + * Block-shaped things drawn outside chunks (moving pistons, falling blocks, the breaking overlay, beacon beams) keep + * the BLOCK format. Because the pipelines are fixed when the game starts, the setting needs a restart; with it off + * at start-up none of this is in the game (VertexMixinPlugin) and chunks are built exactly as vanilla builds them. + * + *

Precision, positions. A chunk vertex position is relative to its section, normally 0 to 16 (a model may stick + * out up to one block more). A half float holds every multiple of 1/16 exactly up to 32, so blocks on the + * sixteenth-of-a-block grid (all full blocks, slabs, stairs, most vanilla models) land on exactly the same float as + * in vanilla and draw the same pixels. Other values (water heights, plants moved off the grid by their random + * offset, rotated model elements) are rounded to the half grid: 1/1024 of a block near 0, 1/128 between 8 and 16, + * 1/64 between 16 and 32. They are rounded towards the centre of the 1/16 cell they are in, never onto its edge, so + * a face kept a hair off a block face (water's sides and top sit 0.001 inside the block next to glass or under a + * block) stays off it and does not start to flicker against it. The rounding depends only on the value, so a + * corner shared by two faces gets the same half in both and no cracks open. + * + *

Precision, texture coordinates. RG16_UNORM steps are 1/65535 of the atlas: under 1/64 texel for an atlas up to + * 1024 wide, 1/16 texel at 4096, 1/8 at 8192. Quads written through the fast path are rounded towards the middle of + * their own texture rectangle, so their edges never reach the next sprite in the atlas (no bleeding lines). When the + * block atlas is wider or taller than 8192 the next start uses float texture coordinates instead (24 bytes, -14%), + * remembered in vesuvio.properties ({@link TerrainFormat#KEY_FLOAT_UV}); positions stay compact. + * + *

Default off: positions off the 1/16 grid and every texture coordinate move by a small fraction of a texel or + * pixel, so the picture is not bit-for-bit vanilla's, though nobody should see the difference. + */ +public final class CompactVertices { + public static final VesuvioConfig.Toggle SETTING = VesuvioConfig.toggle("world", TerrainFormat.KEY, false, true); + + /** Byte offsets inside the compact vertex. */ + public static final int POSITION = 0; + public static final int COLOR = 8; + public static final int UV0 = 12; + + /** The fourth position half, unused by the shaders: 1.0. */ + private static final short HALF_ONE = 0x3C00; + + private CompactVertices() { + } + + /** Creates the setting and the counters (called from Vesuvio.onInitializeClient). */ + public static void init() { + it.cosimo.vesuvio.ui.VesuvioStats.register("terrain vertex bytes", () -> TerrainFormat.format().getVertexSize()); + if (TerrainFormat.active()) { + Vesuvio.LOGGER.info("Vesuvio: compact terrain vertices, {} bytes ({} texture coordinates)", + TerrainFormat.format().getVertexSize(), TerrainFormat.floatUv() ? "float" : "16-bit"); + } + } + + /** + * Called with the size of every new block atlas. A side over {@link TerrainFormat#MAX_UNORM_ATLAS} with 16-bit + * texture coordinates (or the other way round) is noted for the next start; this run keeps its format, since + * the pipelines and every chunk already use it. + */ + public static void noteBlockAtlas(int width, int height) { + boolean wantFloat = Math.max(width, height) > TerrainFormat.MAX_UNORM_ATLAS; + if (wantFloat != Boolean.parseBoolean(VesuvioConfig.raw(TerrainFormat.KEY_FLOAT_UV, "false").trim())) { + VesuvioConfig.setRaw(TerrainFormat.KEY_FLOAT_UV, Boolean.toString(wantFloat)); + } + if (wantFloat != TerrainFormat.floatUv()) { + Vesuvio.LOGGER.warn("Vesuvio: the block atlas is {}x{}; compact terrain vertices switch to {} texture " + + "coordinates at the next start", width, height, wantFloat ? "float" : "16-bit"); + } + } + + // ---- Encoding ---------------------------------------------------------------------------------------------- + + /** Writes a position as four halves (x, y, z, 1). */ + public static void putPosition(long pointer, float x, float y, float z) { + MemoryUtil.memPutShort(pointer, half(x)); + MemoryUtil.memPutShort(pointer + 2L, half(y)); + MemoryUtil.memPutShort(pointer + 4L, half(z)); + MemoryUtil.memPutShort(pointer + 6L, HALF_ONE); + } + + /** + * A coordinate as a half float: exact when it can be (every multiple of 1/16 below 32), otherwise one of the two + * halves around it, the one towards the centre of the 1/16 cell it lies in. The centre and the cell's edges are + * themselves halves (below 32), so the result stays strictly inside the cell: a value off the 1/16 grid never + * lands on it. It depends on the value alone and never reorders two values. Outside +-32 (no vanilla chunk + * vertex gets there) it is plain round-to-nearest. + */ + static short half(float v) { + short h = Float.floatToFloat16(v); + float back = Float.float16ToFloat(h); + if (back == v || !(Math.abs(v) < 32.0F)) { + return h; + } + float center = (float) Math.floor(v * 16.0F) * 0.0625F + 0.03125F; + if (v < center) { + // Round up, towards the centre. + return back < v ? stepUp(h) : h; + } + // v > center (it is not the centre, which is exact): round down. + return back > v ? stepDown(h) : h; + } + + /** The next half towards +infinity from a finite half. */ + private static short stepUp(short h) { + int bits = h & 0xFFFF; + if (bits == 0x8000) { + return 0x0001; + } + return (short) ((bits & 0x8000) == 0 ? bits + 1 : bits - 1); + } + + /** The next half towards -infinity from a finite half. */ + private static short stepDown(short h) { + int bits = h & 0xFFFF; + if (bits == 0x0000) { + return (short) 0x8001; + } + return (short) ((bits & 0x8000) == 0 ? bits - 1 : bits + 1); + } + + /** Writes texture coordinates as 16-bit unorms, each rounded to the nearest step. */ + public static void putUv(long pointer, float u, float v) { + putUnorm(pointer, u); + putUnorm(pointer + 2L, v); + } + + /** Writes one texture coordinate as a 16-bit unorm, rounded to the nearest step. */ + public static void putUnorm(long pointer, float c) { + MemoryUtil.memPutShort(pointer, unorm(c, 0)); + } + + /** + * Rewrites the texture coordinates of the quad whose first vertex is at {@code first}, each rounded towards the + * middle of the quad's own texture rectangle, so that no edge reaches outside it. {@code uv} holds u0 v0 .. u3 v3. + */ + public static void putQuadUvs(long first, int stride, float[] uv) { + float uMid = 0.5F * (Math.min(Math.min(uv[0], uv[2]), Math.min(uv[4], uv[6])) + + Math.max(Math.max(uv[0], uv[2]), Math.max(uv[4], uv[6]))); + float vMid = 0.5F * (Math.min(Math.min(uv[1], uv[3]), Math.min(uv[5], uv[7])) + + Math.max(Math.max(uv[1], uv[3]), Math.max(uv[5], uv[7]))); + for (int i = 0; i < 4; i++) { + long pointer = first + (long) i * stride + UV0; + float u = uv[2 * i]; + float v = uv[2 * i + 1]; + MemoryUtil.memPutShort(pointer, unorm(u, Float.compare(uMid, u))); + MemoryUtil.memPutShort(pointer + 2L, unorm(v, Float.compare(vMid, v))); + } + } + + /** A coordinate in 0..1 as a 16-bit unorm: rounded up for {@code direction} > 0, down for < 0, else nearest. */ + private static short unorm(float c, int direction) { + double scaled = Math.clamp(c, 0.0F, 1.0F) * 65535.0; + double r = direction > 0 ? Math.ceil(scaled) : direction < 0 ? Math.floor(scaled) : Math.rint(scaled); + return (short) (int) r; + } + + // ---- Reading back ------------------------------------------------------------------------------------------ + + /** Whether {@code format} keeps its positions as half floats (the compact formats). */ + public static boolean halfPositions(VertexFormat format) { + VertexFormatElement position = format.getElement("Position"); + return position != null && position.format() == GpuFormat.RGBA16_FLOAT; + } + + /** The coordinate stored as a half at {@code pointer}, as the GPU reads it. */ + public static float getHalf(long pointer) { + return Float.float16ToFloat(MemoryUtil.memGetShort(pointer)); + } + + /** + * MeshData.decodeQuadCentroids for half positions (the translucent sort): the same arithmetic as vanilla on + * the coordinates the GPU sees. Returns false when this format is not one it handles (vanilla runs then). + */ + public static boolean decodeQuadCentroids(ByteBuffer vertexBuffer, int vertexCount, VertexFormat format, + CompactVectorArray output, int outputIndex) { + if (!halfPositions(format) || !vertexBuffer.isDirect()) { + return false; + } + int stride = format.getVertexSize(); + long base = MemoryUtil.memAddress(vertexBuffer) + format.getElement("Position").offset(); + int quadStride = stride * 4; + int quadCount = vertexCount / 4; + for (int i = 0; i < quadCount; i++) { + long first = base + (long) i * quadStride; + long third = first + 2L * stride; + float xMid = (getHalf(first) + getHalf(third)) / 2.0F; + float yMid = (getHalf(first + 2L) + getHalf(third + 2L)) / 2.0F; + float zMid = (getHalf(first + 4L) + getHalf(third + 4L)) / 2.0F; + output.set(outputIndex + i, xMid, yMid, zMid); + } + return true; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/vertex/TerrainFormat.java b/src/main/java/it/cosimo/vesuvio/vertex/TerrainFormat.java new file mode 100644 index 0000000..7195f22 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/vertex/TerrainFormat.java @@ -0,0 +1,67 @@ +package it.cosimo.vesuvio.vertex; + +import com.mojang.blaze3d.vertex.DefaultVertexFormat; +import com.mojang.blaze3d.vertex.VertexFormat; +import com.mojang.renderpearl.api.GpuFormat; + +import it.cosimo.vesuvio.VesuvioConfig; + +/** + * The vertex format of chunk meshes this run, decided once from vesuvio.properties as saved at start-up: the master + * switch and compactTerrainVertices both on (and none of its mixins switched off) give a compact format + * ({@link CompactVertices}), otherwise vanilla's BLOCK: the same decision as VertexMixinPlugin's. Makes no setting, + * so RenderPipelines' set-up can ask before Vesuvio's own start-up. + */ +public final class TerrainFormat { + /** The setting key. */ + public static final String KEY = "compactTerrainVertices"; + /** Hidden key: true once a block atlas wider or taller than {@link #MAX_UNORM_ATLAS} was seen. */ + public static final String KEY_FLOAT_UV = "compactTerrainFloatUv"; + /** Largest block atlas side the 16-bit texture coordinates are used for. */ + public static final int MAX_UNORM_ATLAS = 8192; + + /** 20 bytes: half4 position (w unused), RGBA8 colour, unorm16 texture coordinates, short2 light. */ + public static final VertexFormat UNORM_UV = VertexFormat.builder(0) + .addAttribute("Position", GpuFormat.RGBA16_FLOAT) + .addAttribute("Color", GpuFormat.RGBA8_UNORM) + .addAttribute("UV0", GpuFormat.RG16_UNORM) + .addAttribute("UV2", GpuFormat.RG16_SINT) + .build(); + /** 24 bytes: the same with float texture coordinates, for block atlases over {@link #MAX_UNORM_ATLAS}. */ + public static final VertexFormat FLOAT_UV = VertexFormat.builder(0) + .addAttribute("Position", GpuFormat.RGBA16_FLOAT) + .addAttribute("Color", GpuFormat.RGBA8_UNORM) + .addAttribute("UV0", GpuFormat.RG32_FLOAT) + .addAttribute("UV2", GpuFormat.RG16_SINT) + .build(); + + private static final boolean ACTIVE = VertexMixinPlugin.startDecision(); + private static final boolean USE_FLOAT_UV = on(KEY_FLOAT_UV, false); + + private TerrainFormat() { + } + + private static boolean on(String key, boolean fallback) { + return Boolean.parseBoolean(VesuvioConfig.raw(key, Boolean.toString(fallback)).trim()); + } + + /** Whether chunk meshes use a compact format this run. */ + public static boolean active() { + return ACTIVE; + } + + /** Whether the compact format keeps float texture coordinates (24 bytes) this run. */ + public static boolean floatUv() { + return USE_FLOAT_UV; + } + + /** The vertex format of the chunk pipelines this run, compact or vanilla's. */ + public static VertexFormat format() { + return !ACTIVE ? DefaultVertexFormat.BLOCK : USE_FLOAT_UV ? FLOAT_UV : UNORM_UV; + } + + /** 1 for the 20-byte format, 2 for the 24-byte one, 0 for any other (BufferBuilder's mode). */ + public static int mode(VertexFormat format) { + return format == UNORM_UV ? 1 : format == FLOAT_UV ? 2 : 0; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/vertex/VertexMixinPlugin.java b/src/main/java/it/cosimo/vesuvio/vertex/VertexMixinPlugin.java new file mode 100644 index 0000000..56570cc --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/vertex/VertexMixinPlugin.java @@ -0,0 +1,84 @@ +package it.cosimo.vesuvio.vertex; + +import java.util.List; +import java.util.Set; + +import org.objectweb.asm.tree.ClassNode; +import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; +import org.spongepowered.asm.mixin.extensibility.IMixinInfo; + +import it.cosimo.vesuvio.Vesuvio; +import it.cosimo.vesuvio.VesuvioConfig; +import it.cosimo.vesuvio.VesuvioMixinPlugin; + +/** + * Mixin plugin of vesuvio.vertex.mixins.json. Does what {@link VesuvioMixinPlugin} does (the disabledMixins kill + * list) and leaves out every compact-vertex mixin unless the master switch and compactTerrainVertices were both on + * at start-up: the vertex format is fixed when the game sets up its pipelines, so with the setting off nothing of + * it may be in the game. Reads only the raw file (TerrainFormat's keys are inlined constants): no game class may be + * loaded this early. + */ +public final class VertexMixinPlugin implements IMixinConfigPlugin { + /** + * Every mixin of the config. They work only all together (the pipelines' format, the code that writes it and the + * code that reads it back), so naming any of them in disabledMixins leaves out all of them. + */ + private static final List MIXINS = List.of("BufferBuilderCompactMixin", "MeshDataCompactMixin", + "RenderPipelinesCompactMixin", "TextureAtlasCompactMixin"); + + private final VesuvioMixinPlugin base = new VesuvioMixinPlugin(); + private boolean on; + + /** + * Whether compact vertices are on this run: master switch and setting on in the file at start-up, and none of + * the mixins switched off. TerrainFormat asks the same, so the format and the mixins always agree. + */ + static boolean startDecision() { + if (!Boolean.parseBoolean(VesuvioConfig.raw("enabled", "true").trim()) + || !Boolean.parseBoolean(VesuvioConfig.raw(TerrainFormat.KEY, "false").trim())) { + return false; + } + for (String name : VesuvioConfig.raw("disabledMixins", "").split(",")) { + if (MIXINS.contains(name.trim())) { + return false; + } + } + return true; + } + + @Override + public void onLoad(String mixinPackage) { + base.onLoad(mixinPackage); + on = startDecision(); + if (!on) { + Vesuvio.LOGGER.info("Vesuvio: compact terrain vertices off (setting, master switch or disabledMixins)"); + } + } + + @Override + public String getRefMapperConfig() { + return null; + } + + @Override + public boolean shouldApplyMixin(String targetClassName, String mixinClassName) { + return on && base.shouldApplyMixin(targetClassName, mixinClassName); + } + + @Override + public void acceptTargets(Set myTargets, Set otherTargets) { + } + + @Override + public List getMixins() { + return null; + } + + @Override + public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) { + } + + @Override + public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) { + } +} diff --git a/src/main/java/it/cosimo/vesuvio/vertex/mixin/BufferBuilderCompactMixin.java b/src/main/java/it/cosimo/vesuvio/vertex/mixin/BufferBuilderCompactMixin.java new file mode 100644 index 0000000..3b3f993 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/vertex/mixin/BufferBuilderCompactMixin.java @@ -0,0 +1,170 @@ +package it.cosimo.vesuvio.vertex.mixin; + +import org.lwjgl.system.MemoryUtil; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; +import com.mojang.blaze3d.vertex.BufferBuilder; +import com.mojang.blaze3d.vertex.ByteBufferBuilder; +import com.mojang.blaze3d.vertex.VertexConsumer; +import com.mojang.blaze3d.vertex.VertexFormat; +import com.mojang.renderpearl.api.pipeline.PrimitiveTopology; + +import it.cosimo.vesuvio.vertex.CompactVertices; +import it.cosimo.vesuvio.vertex.TerrainFormat; + +/** + * Setting compactTerrainVertices (only in the game when it was on at start-up): BufferBuilder writes the compact + * chunk format. Vanilla's BufferBuilder writes positions and texture coordinates as floats whatever the format's + * elements say, so a builder made for a compact format (only the section compiler makes those, through + * ChunkSectionLayer.vertexFormat()) needs its own writes: + *

    + *
  • the 11-argument addVertex, vanilla's fast path, used for every block quad (ModelBlockRenderer's + * putBlockBakedQuad, the fluid renderer, Fabric API's Indigo renderer through QuadView.buffer);
  • + *
  • the element-by-element path: addVertex(x, y, z) and setUv. Colour and light (setColor, setUv2, setLight) + * already write at the element's offset in the format they always had, and the overlay and normal are not in + * the format, so vanilla drops them as it does for BLOCK.
  • + *
+ * Builders of every other format (mode 0) run vanilla's code untouched. + * + *

Cost: every hook here is a {@code @WrapWithCondition}, which calls its handler and nothing else (no + * CallbackInfo, no argument array), and each sits on a call only the slow paths make: vanilla's BLOCK and ENTITY + * fast paths of the 11-argument addVertex (all block-model and entity vertices drawn outside chunks) never reach + * one, and the element-by-element path pays one field compare per position and per texture coordinate written. + */ +@Mixin(BufferBuilder.class) +public abstract class BufferBuilderCompactMixin { + @Shadow + private int vertices; + @Shadow + @Final + private int vertexSize; + @Shadow + @Final + private PrimitiveTopology primitiveTopology; + + /** 0: not a compact format (vanilla code); 1: 20 bytes, unorm16 texture coordinates; 2: 24 bytes, float ones. */ + @Unique + private int vesuvio$mode; + /** Texture coordinates of the quad being written (u0 v0 .. u3 v3), mode 1 only. */ + @Unique + private float[] vesuvio$quadUv; + /** Which corners of that quad came through the fast path (bit per corner). */ + @Unique + private int vesuvio$quadCorners; + + @Shadow + private long beginVertex() { + throw new AssertionError(); + } + + @Shadow + private static void putRgba(long pointer, int argb) { + throw new AssertionError(); + } + + @Shadow + private static void putPackedUv(long pointer, int packedUv) { + throw new AssertionError(); + } + + @Inject(method = "", at = @At("RETURN")) + private void vesuvio$compactMode(ByteBufferBuilder buffer, PrimitiveTopology primitiveTopology, VertexFormat format, + CallbackInfo ci) { + this.vesuvio$mode = TerrainFormat.mode(format); + if (this.vesuvio$mode == 1) { + this.vesuvio$quadUv = new float[8]; + } + } + + /** + * The 11-argument addVertex. A compact format is neither BLOCK nor ENTITY, so vanilla's method reaches its last + * branch, {@code VertexConsumer.super.addVertex(...)} (the element-by-element default): a compact builder writes + * the whole vertex here instead and skips that call; any other builder makes it as in vanilla. + */ + @WrapWithCondition(method = "addVertex(FFFIFFIIFFF)V", at = @At(value = "INVOKE", + target = "Lcom/mojang/blaze3d/vertex/VertexConsumer;addVertex(FFFIFFIIFFF)V")) + private boolean vesuvio$compactVertex(VertexConsumer self, float x, float y, float z, int color, float u, float v, + int overlayCoords, int lightCoords, float nx, float ny, float nz) { + int mode = this.vesuvio$mode; + if (mode == 0) { + return true; + } + long pointer = this.beginVertex(); + CompactVertices.putPosition(pointer + CompactVertices.POSITION, x, y, z); + putRgba(pointer + CompactVertices.COLOR, color); + if (mode == 1) { + CompactVertices.putUv(pointer + CompactVertices.UV0, u, v); + putPackedUv(pointer + CompactVertices.UV0 + 4L, lightCoords); + this.vesuvio$quadCorner(pointer, u, v); + } else { + MemoryUtil.memPutFloat(pointer + CompactVertices.UV0, u); + MemoryUtil.memPutFloat(pointer + CompactVertices.UV0 + 4L, v); + putPackedUv(pointer + CompactVertices.UV0 + 8L, lightCoords); + } + return false; + } + + /** + * Keeps the quad's texture coordinates; once its fourth corner is in and all four came through here, rewrites + * them rounded towards the middle of the quad's texture rectangle (CompactVertices.putQuadUvs). + */ + @Unique + private void vesuvio$quadCorner(long pointer, float u, float v) { + if (this.primitiveTopology != PrimitiveTopology.QUADS) { + return; + } + int corner = (this.vertices - 1) & 3; + float[] uv = this.vesuvio$quadUv; + uv[2 * corner] = u; + uv[2 * corner + 1] = v; + int corners = corner == 0 ? 1 : this.vesuvio$quadCorners | 1 << corner; + this.vesuvio$quadCorners = corners; + if (corners == 0xF) { + CompactVertices.putQuadUvs(pointer - 3L * this.vertexSize, this.vertexSize, uv); + } + } + + /** + * addVertex(x, y, z): vanilla has already begun the vertex at the Position element (offset 0 in both compact + * formats) and reset the elements to fill; a compact builder writes the position as halves instead of floats. + */ + @WrapWithCondition(method = "addVertex(FFF)Lcom/mojang/blaze3d/vertex/VertexConsumer;", at = @At(value = "INVOKE", + target = "Lcom/mojang/blaze3d/vertex/BufferBuilder;putVec3f(JFFF)V")) + private boolean vesuvio$compactPosition(long pointer, float x, float y, float z) { + if (this.vesuvio$mode == 0) { + return true; + } + CompactVertices.putPosition(pointer, x, y, z); + this.vesuvio$quadCorners = 0; + return false; + } + + /** setUv, u (first float write, at the UV0 element vanilla found): 16 bits in the 20-byte format. */ + @WrapWithCondition(method = "setUv", at = @At(value = "INVOKE", target = "Lorg/lwjgl/system/MemoryUtil;memPutFloat(JF)V", + ordinal = 0)) + private boolean vesuvio$compactU(long pointer, float u) { + if (this.vesuvio$mode != 1) { + return true; + } + CompactVertices.putUnorm(pointer, u); + return false; + } + + /** setUv, v (second float write, 4 bytes after u in vanilla's layout): right after u's 16 bits. */ + @WrapWithCondition(method = "setUv", at = @At(value = "INVOKE", target = "Lorg/lwjgl/system/MemoryUtil;memPutFloat(JF)V", + ordinal = 1)) + private boolean vesuvio$compactV(long pointerPlus4, float v) { + if (this.vesuvio$mode != 1) { + return true; + } + CompactVertices.putUnorm(pointerPlus4 - 2L, v); + return false; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/vertex/mixin/MeshDataCompactMixin.java b/src/main/java/it/cosimo/vesuvio/vertex/mixin/MeshDataCompactMixin.java new file mode 100644 index 0000000..24e339f --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/vertex/mixin/MeshDataCompactMixin.java @@ -0,0 +1,31 @@ +package it.cosimo.vesuvio.vertex.mixin; + +import java.nio.ByteBuffer; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import com.mojang.blaze3d.vertex.CompactVectorArray; +import com.mojang.blaze3d.vertex.MeshData; +import com.mojang.blaze3d.vertex.VertexFormat; + +import it.cosimo.vesuvio.vertex.CompactVertices; + +/** + * Setting compactTerrainVertices (only in the game when it was on at start-up): the translucent sort reads each + * quad's centre back from the vertex bytes (decodeQuadCentroids, from MeshData.sortQuads), as floats. For a mesh + * in a compact format it reads the half-float positions instead, the values the GPU draws; any other format goes + * through vanilla's code. + */ +@Mixin(MeshData.class) +public abstract class MeshDataCompactMixin { + @Inject(method = "decodeQuadCentroids", at = @At("HEAD"), cancellable = true) + private static void vesuvio$halfCentroids(ByteBuffer vertexBuffer, int vertexCount, VertexFormat format, + CompactVectorArray output, int outputIndex, CallbackInfo ci) { + if (CompactVertices.decodeQuadCentroids(vertexBuffer, vertexCount, format, output, outputIndex)) { + ci.cancel(); + } + } +} diff --git a/src/main/java/it/cosimo/vesuvio/vertex/mixin/RenderPipelinesCompactMixin.java b/src/main/java/it/cosimo/vesuvio/vertex/mixin/RenderPipelinesCompactMixin.java new file mode 100644 index 0000000..b32cfe4 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/vertex/mixin/RenderPipelinesCompactMixin.java @@ -0,0 +1,36 @@ +package it.cosimo.vesuvio.vertex.mixin; + +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.mojang.blaze3d.pipeline.RenderPipeline; + +import it.cosimo.vesuvio.vertex.TerrainFormat; +import net.minecraft.client.renderer.RenderPipelines; + +/** + * Setting compactTerrainVertices (only in the game when it was on at start-up, VertexMixinPlugin): while + * RenderPipelines sets up its static fields, the snippet that gives terrain its shader ("core/terrain", vanilla's + * UNLIT_TERRAIN_SNIPPET, the base of every chunk pipeline: solid, cutout, translucent, wireframe, improved fog, + * multidraw and the improved-transparency sets) also gets the compact vertex format on buffer 0, in place of the + * BLOCK format it took from GENERIC_BLOCKS_SNIPPET. GENERIC_BLOCKS_SNIPPET itself, and so the block pipelines drawn + * outside chunks (moving blocks, falling blocks, block items), keep BLOCK. + * + *

This runs inside RenderPipelines' class initialiser, before any of its fields can be read, so nothing (not + * ChunkSectionLayer, not Etna's copies of the terrain pipelines) ever sees the terrain pipelines with the old format. + */ +@Mixin(RenderPipelines.class) +public abstract class RenderPipelinesCompactMixin { + @WrapOperation(method = "", at = @At(value = "INVOKE", + target = "Lcom/mojang/blaze3d/pipeline/RenderPipeline$Builder;withVertexShader(Ljava/lang/String;)Lcom/mojang/blaze3d/pipeline/RenderPipeline$Builder;")) + private static RenderPipeline.Builder vesuvio$compactTerrainFormat(RenderPipeline.Builder builder, String shader, + Operation original) { + RenderPipeline.Builder result = original.call(builder, shader); + if ("core/terrain".equals(shader) && TerrainFormat.active()) { + result.withVertexBinding(0, TerrainFormat.format()); + } + return result; + } +} diff --git a/src/main/java/it/cosimo/vesuvio/vertex/mixin/TextureAtlasCompactMixin.java b/src/main/java/it/cosimo/vesuvio/vertex/mixin/TextureAtlasCompactMixin.java new file mode 100644 index 0000000..144f739 --- /dev/null +++ b/src/main/java/it/cosimo/vesuvio/vertex/mixin/TextureAtlasCompactMixin.java @@ -0,0 +1,32 @@ +package it.cosimo.vesuvio.vertex.mixin; + +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import it.cosimo.vesuvio.vertex.CompactVertices; +import net.minecraft.client.renderer.texture.SpriteLoader; +import net.minecraft.client.renderer.texture.TextureAtlas; +import net.minecraft.resources.Identifier; + +/** + * Setting compactTerrainVertices (only in the game when it was on at start-up): tells CompactVertices the size of + * each new block atlas, so that one too large for 16-bit texture coordinates switches the next start to float + * ones. Only reads. + */ +@Mixin(TextureAtlas.class) +public abstract class TextureAtlasCompactMixin { + @Shadow + @Final + private Identifier location; + + @Inject(method = "upload", at = @At("HEAD")) + private void vesuvio$noteAtlasSize(SpriteLoader.Preparations preparations, CallbackInfo ci) { + if (TextureAtlas.LOCATION_BLOCKS.equals(this.location)) { + CompactVertices.noteBlockAtlas(preparations.width(), preparations.height()); + } + } +} diff --git a/src/main/resources/assets/vesuvio/lang/en_us.json b/src/main/resources/assets/vesuvio/lang/en_us.json index 0967ef4..0281bea 100644 --- a/src/main/resources/assets/vesuvio/lang/en_us.json +++ b/src/main/resources/assets/vesuvio/lang/en_us.json @@ -1 +1,160 @@ -{} +{ + "vesuvio.option.leafCulling": "Leaf Culling", + "vesuvio.option.leafCulling.tooltip": "Skips the leaf faces buried inside a canopy, which you cannot see but the GPU still draws (also in Etna's shadow pass). Only matters with cutout (fancy) leaves on. 0 = off, same as vanilla. 1 = smart (default): drops a face only when the next three blocks that way are leaves; about half of a forest's leaf faces go, and only about 1 in 50 canopy pixels shows sky where vanilla showed leaves. 2 = aggressive: two blocks are enough, like OptiFine's Smart Leaves; about two thirds go, canopies look a bit thinner (about 1 in 20 pixels). 3 = all: every face between two leaves goes; trees look hollow. Applies at once (the world's chunks are rebuilt), no restart.", + "vesuvio.screen.title": "Vesuvio", + "vesuvio.screen.open": "Vesuvio...", + "vesuvio.screen.reset": "Reset page", + "vesuvio.screen.reset.tooltip": "Puts every option on this page back to its default.", + "vesuvio.screen.masterOff": "Off: the game runs as vanilla", + "vesuvio.screen.restartDue": "Restart the game to apply the options marked *", + "vesuvio.screen.emptyPage": "No options on this page yet.", + "vesuvio.screen.restartLegend": "* needs a restart", + "vesuvio.screen.hint": "Point at an option to see what it does. Every change applies at once and is saved; options marked * apply after a restart.", + "vesuvio.screen.stats": "Live counters", + "vesuvio.screen.noTooltip": "No description yet.", + "vesuvio.screen.default": "Default: %s.", + "vesuvio.screen.defaultRange": "Default: %s (from %s to %s).", + "vesuvio.screen.needsRestart": "Takes effect after a restart.", + "vesuvio.page.general": "General", + "vesuvio.page.rendering": "Rendering", + "vesuvio.page.entities": "Entities", + "vesuvio.page.memory": "Memory", + "vesuvio.page.frame": "Frame", + "vesuvio.page.world": "World", + "vesuvio.option.enabled": "Vesuvio", + "vesuvio.option.enabled.tooltip": "The master switch. Off, every Vesuvio optimization stops at once and the game runs exactly as vanilla. Also on the F8 key (change it in Controls). No restart needed.", + "vesuvio.option.statsLog": "Log counters", + "vesuvio.option.statsLog.tooltip": "Writes fps and Vesuvio's live counters (what each optimization skipped) to latest.log every 5 seconds, for checking or sharing. Costs nothing visible. No restart needed.", + "vesuvio.option.benchmarkSeconds": "Benchmark step (s)", + "vesuvio.option.benchmarkSeconds.tooltip": "How many seconds the benchmark measures each step, after at least 3 seconds to settle (more while the world's chunks are being rebuilt). Longer is steadier but the run takes longer.", + "vesuvio.toggle.on": "Vesuvio: on", + "vesuvio.toggle.off": "Vesuvio: off", + "key.category.vesuvio.main": "Vesuvio", + "key.vesuvio.toggle": "Switch Vesuvio on/off", + "vesuvio.memory.title": "Memory check", + "vesuvio.memory.status.measuring": "Memory: measuring (maximum %s)", + "vesuvio.memory.status.ok": "Memory: fine (uses ~%s of %s)", + "vesuvio.memory.status.low": "Memory: too little (uses ~%s of %s)", + "vesuvio.memory.status.high": "Memory: maximum %s is too high", + "vesuvio.memory.advice.measuring": "Play for two minutes in a world and Vesuvio will know how much memory the game really needs.", + "vesuvio.memory.advice.ok": "The maximum of %s is fine. For this computer anything in %s works the same: more memory does not make the game faster.", + "vesuvio.memory.advice.low": "The game needs about %s and may use at most %s, so it keeps cleaning up memory, which stutters. Raise the maximum to %s.", + "vesuvio.memory.advice.high": "The game may use up to %s of this computer's %s, which leaves too little for the system and the graphics, so it may start swapping and stutter. Lower the maximum to %s.", + "vesuvio.memory.howTo": "The maximum is set in the launcher, before the game starts. Prism Launcher: right-click the instance > Edit > Settings > Java > Maximum memory allocation. Official launcher: Installations > Edit > More options > JVM arguments, -Xmx4G for 4 GB. Click this button to see the advice in chat.", + "vesuvio.memory.chat": "Vesuvio memory check: %s", + "vesuvio.benchmark.button": "Run benchmark", + "vesuvio.benchmark.tooltip": "Measures fps with Vesuvio off, on, and with each optimization changed in turn (each setting on the other pages that needs no restart: a switch is flipped, a number such as Leaf Culling goes to 0), then on and off again, plus one step with GPU Pass Timers on (a table of the graphics card's time per drawing pass goes to latest.log). Stand still somewhere typical and don't touch anything: any key, click, move or menu stops it and puts your settings back. The results show at the end and go to latest.log.", + "vesuvio.benchmark.noWorld": "Join a world first: the benchmark measures the game as you play it.", + "vesuvio.benchmark.started": "Vesuvio benchmark: %s steps, about %s s. Stand still and don't touch anything; any key stops it.", + "vesuvio.benchmark.progress": "Vesuvio benchmark %s/%s, %s left. Don't touch anything.", + "vesuvio.benchmark.partial": "Stopped early: %s of %s steps measured, shown here and in latest.log.", + "vesuvio.benchmark.stopped": "Vesuvio benchmark stopped (%s); settings put back.", + "vesuvio.benchmark.stop.done": "finished", + "vesuvio.benchmark.stop.input": "a key or button was pressed", + "vesuvio.benchmark.stop.moved": "the player moved", + "vesuvio.benchmark.stop.menu": "a menu opened", + "vesuvio.benchmark.stop.minimized": "the window was minimized", + "vesuvio.benchmark.stop.left_world": "the world was left", + "vesuvio.benchmark.stop.error": "an error, see latest.log", + "vesuvio.benchmark.stop.other": "stopped", + "vesuvio.benchmark.done": "Vesuvio benchmark done: off %s fps, on %s fps (%s%%). Table in latest.log.", + "vesuvio.benchmark.title": "Vesuvio benchmark", + "vesuvio.benchmark.summary": "Off: %s fps On: %s fps (%s%%)", + "vesuvio.benchmark.col.step": "Step", + "vesuvio.benchmark.col.fps": "fps", + "vesuvio.benchmark.col.frame": "ms", + "vesuvio.benchmark.col.p95": "p95 ms", + "vesuvio.benchmark.col.vsOn": "vs on", + "vesuvio.benchmark.footer": "vs on: ms per frame compared with Vesuvio on; + means what the step switched off was saving time.", + "vesuvio.option.memoryLightThreadChecks": "Light Chunk Locks", + "vesuvio.option.memoryLightThreadChecks.tooltip": "Chunk data uses a tiny built-in check instead of a lock set (about 128 bytes) in every chunk section, and in every copy made to build or save one. Saves roughly 10 MB at render distance 12 and 50 MB at 32, with less garbage while chunks load. Still crashes with the same clear report if a mod misuses chunk data from two threads. No visual change. Needs a restart.", + "vesuvio.option.memoryShareBlockShapes": "Share Block Shapes", + "vesuvio.option.memoryShareBlockShapes.tooltip": "Blocks with identical shapes (walls, fences, panes, bars, fire, redstone...) share one shape object instead of thousands of copies, and walls/fences/panes reuse the shapes of the first block of their kind instead of computing them again. Saves roughly 10 MB and a little start-up time. No visual or gameplay change. Needs a restart.", + "vesuvio.option.memoryShareStateTables": "Share Block State Tables", + "vesuvio.option.memoryShareStateTables.tooltip": "Block states with the same face-support and face-occlusion tables share one table. Saves about 1.5 MB. No visual or gameplay change. Needs a restart.", + "vesuvio.option.memoryShareIdNamespaces": "Share Id Names", + "vesuvio.option.memoryShareIdNamespaces.tooltip": "Ids read from game files and the network (\"minecraft:stone\") share one \"minecraft\" text instead of each keeping a copy. Saves about 1 MB. No visual or gameplay change. Needs a restart.", + "vesuvio.option.faceCulling": "Face Culling", + "vesuvio.option.faceCulling.tooltip": "Skips the faces of terrain that point away from you before the graphics card works on them (it would throw them away later anyway), saving about half the vertex work of the terrain. The picture does not change. Live: sections built while it was off are rebuilt in the background over a few seconds.", + "vesuvio.option.faceCullingMinGap": "Face Culling: Split From", + "vesuvio.option.faceCullingMinGap.tooltip": "How many hidden faces in the middle of a section are worth one extra draw call to skip, when the graphics card's time is what counts. Lower skips more faces but adds draw calls (CPU time); higher keeps draws down. Hidden faces at either end of a section are always skipped for free. On the Mac with Smart Splits on, it applies only while the graphics card is the limit. No look change, live.", + "vesuvio.option.faceCullingAutoSplit": "Face Culling: Smart Splits (Mac)", + "vesuvio.option.faceCullingAutoSplit.tooltip": "macOS only: splits a section's draw to skip hidden faces in its middle only while the graphics card is the limit (Vesuvio measures it every second: the game waits for the graphics card at least 10% of the frame). While the CPU is the limit no draw is split: on the Mac each extra draw costs CPU time and the work it saves lands on a graphics card that is waiting anyway. Hidden faces at either end are still skipped. Off: Split From always applies as set. Same picture either way, live. Does nothing on Windows and Linux.", + "vesuvio.option.sectionDrawCache": "Reuse Terrain Draw Lists", + "vesuvio.option.sectionDrawCache.tooltip": "The list of terrain draws for what you see is rebuilt only when something it depends on changed: which sections are visible, their meshes, where they sit in graphics memory, a section fading in, or you crossing a face culling boundary. Otherwise last frame's list is used again. Saves CPU time and garbage every frame, mostly when standing or looking around slowly. Same picture. Live.", + "vesuvio.option.skipRedundantBinds": "Skip Repeated Binds", + "vesuvio.option.skipRedundantBinds.tooltip": "In each render pass, does not bind again the shader pipeline that is already bound, and does not build a new set of textures and buffers for a draw when the set already bound holds exactly the same ones. Entities, particles and the menus repeat these for every single draw. Saves CPU time per draw (most on the Mac, where MoltenVK turns each one into Metal work). Same picture. Live.", + "vesuvio.option.fogCulling": "Fog Culling", + "vesuvio.option.fogCulling.tooltip": "Skips terrain, mobs, block entities and particles so deep in the fog that they would be drawn in the plain fog colour anyway: under water, in lava or powder snow, with Blindness or Darkness, in the Nether and in a boss's fog. Uses the game's own fog distance of each frame, so nothing you could see disappears (in the open air the fog ends past the render distance and nothing is skipped). Turns itself off with Improved Transparency, near the world border, with debug overlays and while Etna Shaders draws the world. No restart.", + "vesuvio.option.entityCulling": "Entity Culling", + "vesuvio.option.entityCulling.tooltip": "Skips drawing mobs, animals, item frames, dropped items and other entities hidden behind solid blocks (checked on a background thread). Looks the same, but an entity coming out from behind a wall can appear a frame late. Entities with a glowing outline, a visible name tag, a lead, or that you ride are always drawn. Works right away, no restart.", + "vesuvio.option.blockEntityCulling": "Block Entity Culling", + "vesuvio.option.blockEntityCulling.tooltip": "Skips drawing chests, signs, banners, shulker boxes, skulls and other block entities that are outside your view or hidden behind solid blocks. Beacon beams, end gateways and structure blocks are always drawn. Looks the same, but one coming into view from behind a wall can appear a frame late. Works right away, no restart.", + "vesuvio.option.particleCulling": "Particle Culling", + "vesuvio.option.particleCulling.tooltip": "Skips particles in places the game already knows you cannot see (the same check that hides caves and rooms behind walls). Particles outside your view are already skipped by the game. No change in look. Works right away, no restart.", + "vesuvio.option.signTextCulling": "Sign Text Culling", + "vesuvio.option.signTextCulling.tooltip": "Skips the text on the side of a sign that faces away from you (the graphics card would throw it away anyway), and all of a sign's text once each letter is smaller than one pixel at your window size and field of view. No change in look. No restart.", + "vesuvio.option.itemFrameCulling": "Item Frame Culling", + "vesuvio.option.itemFrameCulling.tooltip": "Skips item frames (with their item or map) when you are behind the wall they hang on and every block of that wall in the way is solid, and the framed item or map alone once it is smaller than one pixel. No change in look. No restart.", + "vesuvio.option.beaconBeamCulling": "Beacon Beam Culling", + "vesuvio.option.beaconBeamCulling.tooltip": "The game draws every beacon beam every frame, even behind you. Skips beams wholly outside your view (with room for view bobbing and the tilt when hurt). No change in look. No restart.", + "vesuvio.option.fastEntityModels": "Faster Entity Models", + "vesuvio.option.fastEntityModels.tooltip": "Mobs, players, chests and other models are made of boxes. The game moves each corner of a box into place three times (once for each face that uses it); this does it once per corner. Exactly the same vertices, bit for bit, so no change in look. Less CPU time with many mobs (farms, villages). No restart.", + "vesuvio.option.signTextCache": "Cache Sign Text", + "vesuvio.option.signTextCache.tooltip": "The game measures and lays out the text of every visible sign again each frame. This keeps each line's layout and only redraws it where the sign is. Same letters, same place, no change in look; rebuilt after a resource pack or font change. Obfuscated (magic) text is never kept. Helps in towns and bases with many signs. No restart.", + "vesuvio.option.animatedTextures": "Animate Only Visible Textures", + "vesuvio.option.animatedTextures.tooltip": "Animated block textures (water, lava, fire, portals, sea lanterns...) are only redrawn while something on screen uses them. Saves GPU work on every texture tick. No change in look: a texture that comes into view starts moving again within one tick (1/20 s). Takes full effect for chunks built after it is switched on. No restart.", + "vesuvio.option.idleFps": "Reduce FPS When Idle", + "vesuvio.option.idleFps.tooltip": "When the game window is not focused, the frame rate is capped (see Idle FPS Limit). When it is minimized, almost nothing is drawn (one frame a second) while the game keeps running. Playing in the focused window is never affected. No restart.", + "vesuvio.option.idleFpsLimit": "Idle FPS Limit", + "vesuvio.option.idleFpsLimit.tooltip": "Frame rate while the window is open but not focused (3 to 60). The game itself keeps running at full speed. No restart.", + "vesuvio.option.hiddenWindowFps": "Treat Hidden Window as Minimized", + "vesuvio.option.hiddenWindowFps.tooltip": "When the game window is hidden or completely covered (on macOS: Cmd+H, another Space or other windows over it all), almost nothing is drawn, as when it is minimized, while the game keeps running. Only works where the system tells the game; otherwise nothing changes. No restart.", + "vesuvio.option.pausedFps": "Cap FPS When Paused", + "vesuvio.option.pausedFps.tooltip": "In single player, while the game is paused behind a menu (Esc, options), the frame rate is capped at Paused FPS Limit: the world stands still, so more frames only make heat and fan noise. No restart.", + "vesuvio.option.pausedFpsLimit": "Paused FPS Limit", + "vesuvio.option.pausedFpsLimit.tooltip": "Frame rate while single player is paused behind a menu (10 to 240). Default 60. No restart.", + "vesuvio.option.fastStartupCompile": "Faster Shader Compiling (Mac)", + "vesuvio.option.fastStartupCompile.tooltip": "macOS only: asks MoltenVK to let Metal compile shaders on more threads at once, so the short stutter when a new effect or block appears for the first time is shorter. No change in look. Needs a restart. Does nothing on Windows and Linux.", + "vesuvio.option.pipelineCache": "Keep Compiled Shaders", + "vesuvio.option.pipelineCache.tooltip": "Saves the graphics driver's compiled shaders (Vulkan pipeline cache) in the vesuvio-cache folder when you quit, and gives them back at the next launch, so the game starts faster and the first sight of a new mob, effect or shader pass no longer hitches. The file is checked against the graphics card and driver and thrown away after a driver update. Biggest gain on Windows; on Mac Metal keeps its own cache, so the gain is smaller. No visual change. Needs a restart.", + "vesuvio.option.asyncSubmit": "Asynchronous Submit (Mac, experimental)", + "vesuvio.option.asyncSubmit.tooltip": "macOS only: MoltenVK converts each frame to Metal on its own thread instead of the game's. Can raise FPS when the CPU is the limit; does nothing when the GPU is. Experimental: switch it off if you see flicker, stutter or crashes. No change in look. Needs a restart. Does nothing on Windows and Linux.", + "vesuvio.option.metalArgumentBuffersOff": "No Metal Argument Buffers (Mac, experiment)", + "vesuvio.option.metalArgumentBuffersOff.tooltip": "macOS only, an experiment to measure: tells MoltenVK not to use Metal argument buffers, so the textures and buffers of each draw are bound one by one instead of through one table. The game makes a new table for almost every draw, so this may be faster or slower: run the Vesuvio benchmark with it off and on and keep whichever wins. Shader packs that use many textures at once (more than 16 in one shader) may fail to draw with it on; switch it off then. No change in look otherwise. Off by default. Needs a restart. Does nothing on Windows and Linux.", + "vesuvio.option.mergeTransferBarriers": "Merge Upload Barriers", + "vesuvio.option.mergeTransferBarriers.tooltip": "After every copy to the graphics card (chunks, uniforms, entity and GUI vertices, textures) the game makes the GPU stop and wait until everything before it is finished. Copies that touch different memory do not need that, so back-to-back copies like these now share one wait, recorded before anything that could use their data. On Mac each wait also starts a new Metal command group, so loading chunks gets smoother. Same picture. No restart.", + "vesuvio.option.framebufferCache": "Keep Framebuffers", + "vesuvio.option.framebufferCache.tooltip": "Every time the game starts drawing into a set of pictures it makes a Vulkan framebuffer (the list of those pictures) and throws it away after the frame, around 20 times a frame and many more with shader packs. Now each one is kept and reused while the same pictures are drawn into again, and thrown away when one of its pictures is (resizing the window, changing shader packs). A little less CPU time per frame. Same picture. No restart.", + "vesuvio.option.mappedVertexPools": "Direct Vertex Writes", + "vesuvio.option.mappedVertexPools.tooltip": "Only on computers whose graphics share the main memory (Apple silicon and other integrated GPUs; checked from what the driver reports): the vertices of mobs, block entities, particles and the GUI are written straight into the memory the graphics card draws them from, instead of into a staging buffer that the graphics card then copies over. Two to four GPU copies and waits less per frame. A buffer is only rewritten after the graphics card has finished every frame that used it. Same picture. Does nothing on other computers. No restart.", + "vesuvio.option.gpuPassTimers": "GPU Pass Timers", + "vesuvio.option.gpuPassTimers.tooltip": "A diagnostic: measures how long the graphics card spends on each drawing pass of the frame (terrain, entities, clouds, the GUI, each pass of a shader pack such as Etna's shadows, water and post effects), using GPU timestamps written between passes. Shown on F3 (with the Vesuvio entry): the whole frame and the five slowest passes, averaged over a second. The benchmark turns it on by itself for one step and writes a full table to latest.log. Same picture. Costs a little GPU time while on, more on Mac, so leave it off for playing. No restart.", + "vesuvio.option.gpuPassTimers.unsupported": "Not supported by this graphics driver: it does nothing.", + "vesuvio.option.worldEntitySectionLookup": "Fast Entity Lookups", + "vesuvio.option.worldEntitySectionLookup.tooltip": "When the game looks for entities near a spot (mobs pushing each other, hoppers picking up items, mobs looking for targets), it checks only the few areas around that spot instead of walking a whole sorted list. Same entities, same order: no change in gameplay or looks. Works in single player and for your own player in multiplayer. No restart.", + "vesuvio.option.worldRareEntityIndex": "Skip Entities Nothing Can Bump", + "vesuvio.option.worldRareEntityIndex.tooltip": "Only boats, minecarts, shulkers and happy ghasts can be bumped into, and only container minecarts and chest boats can feed a hopper. Each area keeps a short list of those, so moving mobs, dropped items and idle hoppers stop checking every cow and item around them. Same results as vanilla: no change in gameplay. Big help in mob farms and item collection areas. Needs a restart.", + "vesuvio.option.worldPathTypeCache": "Remember Pathfinding Block Types", + "vesuvio.option.worldPathTypeCache.tooltip": "Mobs looking for a path ask \"what kind of block is this\" for hundreds of blocks; the answer only depends on the block, so it is worked out once per block type and remembered (forgotten again when data packs reload). Same paths as vanilla. Single player only (on a server the server does pathfinding). No restart.", + "vesuvio.option.worldPoiVerticalRange": "Narrow Villager and Bee Searches", + "vesuvio.option.worldPoiVerticalRange.tooltip": "Villagers looking for beds and workstations, bees looking for hives and golem spawning search a sphere around them, but vanilla reads every height of every chunk in it first. This reads only the heights the sphere reaches. Same results, same order: no change in gameplay. Single player only. No restart.", + "vesuvio.option.worldFastSuffocationCheck": "Faster Suffocation Check", + "vesuvio.option.worldFastSuffocationCheck.tooltip": "Every mob checks each tick whether its head is inside a block. This does the same check without the extra objects vanilla creates for it. Same result: no change in gameplay. Single player only. No restart.", + "vesuvio.option.chunkScheduler": "Smooth Chunk Building", + "vesuvio.option.chunkScheduler.tooltip": "Hands the chunk-building threads only as many sections per frame as they can work through, nearest to you first, instead of copying every changed section in view in one go. Removes the long frames when you join a world, teleport or fly into new terrain, and the threads always work on what is in front of you now. Sections you change and those within about 27 blocks are still built at once. Same picture: a waiting section keeps showing what it showed before and is built a few frames later. No restart.", + "vesuvio.option.skipEmptySections": "Skip Empty Sections", + "vesuvio.option.skipEmptySections.tooltip": "A 16x16x16 piece of the world that holds only air (most of the sky above the ground) gets its empty result straight away, without copying the world around it and without a building task. Vanilla builds every one of them, including again after each light change. Same picture. No restart.", + "vesuvio.option.trimSectionCopies": "Lighter Section Copies", + "vesuvio.option.trimSectionCopies.tooltip": "To build a piece of the world the game copies it and its neighbours; each copy also took a copy of the whole chunk's list of chests, signs and other block entities. Now each copy keeps only its own. Less garbage and fewer memory pauses in bases full of storage. Same picture. No restart.", + "vesuvio.option.chunkUploadBudget": "Chunk Upload Budget (ms)", + "vesuvio.option.chunkUploadBudget.tooltip": "Most milliseconds per frame spent sending finished chunks to the graphics card. Vanilla sends them all in the same frame, which can be tens of megabytes after a join or while flying: one long frame. With a budget the rest goes in the next frames; a chunk still appears only once it is complete, so you may see a new chunk a frame or two later, never half of one. If chunks keep waiting the budget grows by itself, and after 10 frames everything is sent at once. 0 = no limit (vanilla). Default 2. No restart.", + "vesuvio.option.mappedChunkStaging": "Direct Chunk Staging", + "vesuvio.option.mappedChunkStaging.tooltip": "Finished chunks are written straight into memory the graphics card can read, instead of being copied once more on the CPU before every upload. Uses up to about 150 MB more memory while on (the 98 MB vanilla staging area stays reserved but unused). Works together with the chunk upload budget. Same picture. No restart: takes effect the next time no chunk is waiting to be sent, usually within a frame.", + "vesuvio.option.compactTerrainVertices": "Compact Terrain Vertices", + "vesuvio.option.compactTerrainVertices.tooltip": "Stores the world's blocks in 20 bytes per corner instead of 28: positions as half-precision numbers, texture coordinates as 16-bit numbers. About 29% less graphics memory, upload bandwidth and vertex reading for terrain, most useful with Etna's shadows, which read every block corner once more. Works with Etna and with Fabric API's renderer. Blocks on the usual sixteenth-of-a-block grid keep exactly the same position; water surfaces, plants and rotated shapes may move by up to 1/128 of a block, and textures by a small fraction of a texel, so the picture is not bit-for-bit vanilla's (that is why it is off by default). With a texture atlas larger than 8192 (huge resource packs) the next start keeps float texture coordinates: 24 bytes. Needs a restart.", + "vesuvio.option.translucentSortThrottle": "Fewer Water/Glass Re-sorts", + "vesuvio.option.translucentSortThrottle.tooltip": "See-through blocks (water, glass, ice) are sorted back to front again as you move. Vanilla does it on every block you walk for far sections lined up with you, e.g. a whole sea at your height. Now a far section is re-sorted once you have moved far enough for its order to change (every 2 blocks at 64 blocks away, every 8 at 256): the sort is never further off than vanilla allows for a section 32 blocks away. Nearby sections are sorted as in vanilla. Does nothing with Improved Transparency. No restart.", + "vesuvio.option.clearFusion": "Clear Fusion", + "vesuvio.option.clearFusion.tooltip": "When the game clears a picture (to a colour or to empty depth) and then draws into it, the clear now happens as the drawing starts instead of as a separate step that writes the whole picture out and reads it back in. Clears nothing can see (the GUI's depth clears) are skipped, and post effects stop making depth buffers they never read. Helps most with shader packs, menu blur and on Apple GPUs. The picture does not change. No restart." +} diff --git a/src/main/resources/assets/vesuvio/lang/it_it.json b/src/main/resources/assets/vesuvio/lang/it_it.json new file mode 100644 index 0000000..731bea6 --- /dev/null +++ b/src/main/resources/assets/vesuvio/lang/it_it.json @@ -0,0 +1,160 @@ +{ + "vesuvio.option.leafCulling": "Culling delle foglie", + "vesuvio.option.leafCulling.tooltip": "Non disegna le facce delle foglie sepolte dentro una chioma, che non si vedono ma la GPU disegna lo stesso (anche nel passaggio delle ombre di Etna). Conta solo con le foglie cutout (dettagliate) attive. 0 = spento, come vanilla. 1 = intelligente (predefinito): toglie una faccia solo se i tre blocchi successivi in quella direzione sono foglie; sparisce circa metà delle facce di foglie di una foresta e solo circa 1 pixel su 50 della chioma mostra il cielo dove vanilla mostrava foglie. 2 = aggressivo: bastano due blocchi, come Smart Leaves di OptiFine; ne spariscono circa due terzi, le chiome sembrano un po' più rade (circa 1 pixel su 20). 3 = tutte: toglie ogni faccia tra due foglie; gli alberi sembrano vuoti dentro. Si applica subito (i chunk vengono ricostruiti), senza riavvio.", + "vesuvio.screen.title": "Vesuvio", + "vesuvio.screen.open": "Vesuvio...", + "vesuvio.screen.reset": "Ripristina pagina", + "vesuvio.screen.reset.tooltip": "Riporta ogni opzione di questa pagina al valore predefinito.", + "vesuvio.screen.masterOff": "Spento: il gioco va come vanilla", + "vesuvio.screen.restartDue": "Riavvia il gioco per applicare le opzioni con *", + "vesuvio.screen.emptyPage": "Ancora nessuna opzione in questa pagina.", + "vesuvio.screen.restartLegend": "* richiede un riavvio", + "vesuvio.screen.hint": "Passa sopra un'opzione per vedere cosa fa. Ogni modifica vale subito e viene salvata; le opzioni con * valgono dopo un riavvio.", + "vesuvio.screen.stats": "Contatori in tempo reale", + "vesuvio.screen.noTooltip": "Ancora nessuna descrizione.", + "vesuvio.screen.default": "Predefinito: %s.", + "vesuvio.screen.defaultRange": "Predefinito: %s (da %s a %s).", + "vesuvio.screen.needsRestart": "Vale dopo un riavvio.", + "vesuvio.page.general": "Generale", + "vesuvio.page.rendering": "Rendering", + "vesuvio.page.entities": "Entità", + "vesuvio.page.memory": "Memoria", + "vesuvio.page.frame": "Frame", + "vesuvio.page.world": "Mondo", + "vesuvio.option.enabled": "Vesuvio", + "vesuvio.option.enabled.tooltip": "L'interruttore generale. Spento, tutte le ottimizzazioni di Vesuvio si fermano subito e il gioco va esattamente come vanilla. Anche col tasto F8 (si cambia in Comandi). Non serve riavviare.", + "vesuvio.option.statsLog": "Registra contatori", + "vesuvio.option.statsLog.tooltip": "Scrive in latest.log ogni 5 secondi gli fps e i contatori di Vesuvio (cosa ha saltato ogni ottimizzazione), per controllarli o condividerli. Non cambia nulla a schermo. Non serve riavviare.", + "vesuvio.option.benchmarkSeconds": "Passo benchmark (s)", + "vesuvio.option.benchmarkSeconds.tooltip": "Quanti secondi il benchmark misura ogni passo, dopo almeno 3 secondi di assestamento (di più mentre i chunk del mondo vengono ricostruiti). Più lungo è più stabile, ma la prova dura di più.", + "vesuvio.toggle.on": "Vesuvio: acceso", + "vesuvio.toggle.off": "Vesuvio: spento", + "key.category.vesuvio.main": "Vesuvio", + "key.vesuvio.toggle": "Accendi/spegni Vesuvio", + "vesuvio.memory.title": "Controllo memoria", + "vesuvio.memory.status.measuring": "Memoria: misurazione (massimo %s)", + "vesuvio.memory.status.ok": "Memoria: va bene (usa ~%s di %s)", + "vesuvio.memory.status.low": "Memoria: troppo poca (usa ~%s di %s)", + "vesuvio.memory.status.high": "Memoria: massimo %s troppo alto", + "vesuvio.memory.advice.measuring": "Gioca due minuti in un mondo e Vesuvio saprà quanta memoria serve davvero al gioco.", + "vesuvio.memory.advice.ok": "Il massimo di %s va bene. Su questo computer qualsiasi valore tra %s funziona allo stesso modo: più memoria non rende il gioco più veloce.", + "vesuvio.memory.advice.low": "Al gioco servono circa %s e può usarne al massimo %s, quindi continua a liberare memoria e scatta. Alza il massimo a %s.", + "vesuvio.memory.advice.high": "Il gioco può usare fino a %s dei %s di questo computer: ne resta troppo poca al sistema e alla grafica, che può iniziare a usare lo swap e scattare. Abbassa il massimo a %s.", + "vesuvio.memory.howTo": "Il massimo si imposta nel launcher, prima di avviare il gioco. Prism Launcher: tasto destro sull'istanza > Modifica > Impostazioni > Java > Allocazione massima di memoria. Launcher ufficiale: Installazioni > Modifica > Altre opzioni > Argomenti JVM, -Xmx4G per 4 GB. Clicca questo pulsante per vedere il consiglio in chat.", + "vesuvio.memory.chat": "Controllo memoria Vesuvio: %s", + "vesuvio.benchmark.button": "Avvia benchmark", + "vesuvio.benchmark.tooltip": "Misura gli fps con Vesuvio spento, acceso e con ogni ottimizzazione cambiata a turno (ogni impostazione delle altre pagine che non richiede riavvio: un interruttore viene invertito, un numero come Culling delle foglie va a 0), poi di nuovo acceso e spento, più un passo con i Timer dei passaggi GPU accesi (una tabella del tempo della scheda grafica per ogni passaggio di disegno va in latest.log). Resta fermo in un punto tipico e non toccare nulla: qualsiasi tasto, clic, movimento o menu lo ferma e rimette le tue impostazioni. I risultati appaiono alla fine e vanno in latest.log.", + "vesuvio.benchmark.noWorld": "Entra prima in un mondo: il benchmark misura il gioco mentre giochi.", + "vesuvio.benchmark.started": "Benchmark Vesuvio: %s passi, circa %s s. Resta fermo e non toccare nulla; qualsiasi tasto lo ferma.", + "vesuvio.benchmark.progress": "Benchmark Vesuvio %s/%s, mancano %s. Non toccare nulla.", + "vesuvio.benchmark.partial": "Fermato prima della fine: misurati %s passi su %s, qui e in latest.log.", + "vesuvio.benchmark.stopped": "Benchmark Vesuvio fermato (%s); impostazioni ripristinate.", + "vesuvio.benchmark.stop.done": "finito", + "vesuvio.benchmark.stop.input": "è stato premuto un tasto o un pulsante", + "vesuvio.benchmark.stop.moved": "il giocatore si è mosso", + "vesuvio.benchmark.stop.menu": "si è aperto un menu", + "vesuvio.benchmark.stop.minimized": "la finestra è stata ridotta a icona", + "vesuvio.benchmark.stop.left_world": "sei uscito dal mondo", + "vesuvio.benchmark.stop.error": "un errore, vedi latest.log", + "vesuvio.benchmark.stop.other": "fermato", + "vesuvio.benchmark.done": "Benchmark Vesuvio finito: spento %s fps, acceso %s fps (%s%%). Tabella in latest.log.", + "vesuvio.benchmark.title": "Benchmark Vesuvio", + "vesuvio.benchmark.summary": "Spento: %s fps Acceso: %s fps (%s%%)", + "vesuvio.benchmark.col.step": "Passo", + "vesuvio.benchmark.col.fps": "fps", + "vesuvio.benchmark.col.frame": "ms", + "vesuvio.benchmark.col.p95": "p95 ms", + "vesuvio.benchmark.col.vsOn": "vs acceso", + "vesuvio.benchmark.footer": "vs acceso: ms per frame rispetto a Vesuvio acceso; + vuol dire che ciò che il passo ha spento faceva risparmiare tempo.", + "vesuvio.option.memoryLightThreadChecks": "Blocchi chunk leggeri", + "vesuvio.option.memoryLightThreadChecks.tooltip": "I dati dei chunk usano un piccolo controllo interno invece di una serie di lock (circa 128 byte) in ogni sezione di chunk e in ogni copia fatta per costruirla o salvarla. Risparmia circa 10 MB a distanza di rendering 12 e 50 MB a 32, con meno spazzatura mentre i chunk caricano. Se una mod usa i dati dei chunk da due thread insieme, il gioco si ferma con lo stesso rapporto chiaro di prima. Nessun cambiamento visivo. Serve un riavvio.", + "vesuvio.option.memoryShareBlockShapes": "Condividi forme dei blocchi", + "vesuvio.option.memoryShareBlockShapes.tooltip": "I blocchi con forme identiche (muretti, staccionate, pannelli, sbarre, fuoco, redstone...) condividono un'unica forma invece di migliaia di copie, e muretti/staccionate/pannelli riusano le forme del primo blocco del loro tipo invece di ricalcolarle. Risparmia circa 10 MB e un po' di tempo all'avvio. Nessun cambiamento visivo o di gioco. Serve un riavvio.", + "vesuvio.option.memoryShareStateTables": "Condividi tabelle degli stati", + "vesuvio.option.memoryShareStateTables.tooltip": "Gli stati dei blocchi con le stesse tabelle di appoggio e di occlusione delle facce condividono un'unica tabella. Risparmia circa 1,5 MB. Nessun cambiamento visivo o di gioco. Serve un riavvio.", + "vesuvio.option.memoryShareIdNamespaces": "Condividi nomi degli id", + "vesuvio.option.memoryShareIdNamespaces.tooltip": "Gli id letti dai file del gioco e dalla rete (\"minecraft:stone\") condividono un unico testo \"minecraft\" invece di tenerne ognuno una copia. Risparmia circa 1 MB. Nessun cambiamento visivo o di gioco. Serve un riavvio.", + "vesuvio.option.faceCulling": "Culling delle facce", + "vesuvio.option.faceCulling.tooltip": "Salta le facce del terreno rivolte dalla parte opposta rispetto a te prima che la scheda grafica ci lavori (le scarterebbe comunque più tardi): circa metà del lavoro sui vertici del terreno in meno. L'immagine non cambia. Subito attivo: le sezioni costruite mentre era spento vengono ricostruite in sottofondo in pochi secondi.", + "vesuvio.option.faceCullingMinGap": "Culling delle facce: dividi da", + "vesuvio.option.faceCullingMinGap.tooltip": "Quante facce nascoste in mezzo a una sezione valgono una chiamata di disegno in più per saltarle. Più basso salta più facce ma aggiunge chiamate (tempo di CPU); più alto ne aggiunge meno. Le facce nascoste ai due capi di una sezione si saltano sempre gratis. Sul Mac con le divisioni intelligenti attive vale solo mentre il limite è la scheda grafica. Nessun cambio d'aspetto, subito attivo.", + "vesuvio.option.faceCullingAutoSplit": "Culling delle facce: divisioni intelligenti (Mac)", + "vesuvio.option.faceCullingAutoSplit.tooltip": "Solo macOS: divide il disegno di una sezione per saltare le facce nascoste in mezzo solo mentre il limite è la scheda grafica (Vesuvio lo misura ogni secondo: il gioco aspetta la scheda grafica per almeno il 10% del fotogramma). Mentre il limite è la CPU nessun disegno viene diviso: sul Mac ogni disegno in più costa tempo di CPU e il lavoro risparmiato cade su una scheda grafica che sta comunque aspettando. Le facce nascoste ai due capi si saltano sempre. Spento: \"Dividi da\" vale sempre com'è impostato. Stessa immagine in ogni caso, subito attivo. Su Windows e Linux non fa nulla.", + "vesuvio.option.sectionDrawCache": "Riusa le liste di disegno del terreno", + "vesuvio.option.sectionDrawCache.tooltip": "La lista dei disegni del terreno per ciò che vedi viene ricostruita solo quando cambia qualcosa da cui dipende: quali sezioni sono visibili, le loro mesh, dove stanno nella memoria grafica, una sezione in dissolvenza, o il passaggio oltre un confine del culling delle facce. Altrimenti si riusa la lista del fotogramma prima. Meno tempo di CPU e meno spazzatura a ogni fotogramma, soprattutto da fermo o girandosi piano. Stessa immagine. Subito attivo.", + "vesuvio.option.skipRedundantBinds": "Salta i bind ripetuti", + "vesuvio.option.skipRedundantBinds.tooltip": "In ogni passata di disegno non ricollega la pipeline di shader già collegata, e non costruisce un nuovo insieme di texture e buffer per un disegno quando quello già collegato contiene esattamente gli stessi. Entità, particelle e menu li ripetono per ogni singolo disegno. Meno tempo di CPU per disegno (soprattutto sul Mac, dove MoltenVK trasforma ognuno in lavoro Metal). Stessa immagine. Subito attivo.", + "vesuvio.option.fogCulling": "Culling nella nebbia", + "vesuvio.option.fogCulling.tooltip": "Non disegna terreno, creature, blocchi con entità e particelle così dentro la nebbia che verrebbero disegnati comunque del solo colore della nebbia: sott'acqua, nella lava o nella neve polverosa, con Cecità o Oscurità, nel Nether e nella nebbia di un boss. Usa la distanza della nebbia del gioco a ogni frame, quindi non sparisce nulla che potresti vedere (all'aperto la nebbia finisce oltre la distanza di rendering e non si salta nulla). Si spegne da solo con la Trasparenza migliorata, vicino al confine del mondo, con le sovrapposizioni di debug e mentre Etna Shaders disegna il mondo. Senza riavvio.", + "vesuvio.option.entityCulling": "Culling delle entità", + "vesuvio.option.entityCulling.tooltip": "Non disegna mob, animali, cornici, oggetti a terra e altre entità nascoste dietro blocchi pieni (il controllo gira su un thread in background). L'aspetto non cambia, ma un'entità che sbuca da dietro un muro può comparire con un fotogramma di ritardo. Le entità con Luminescenza, con il nome visibile, al guinzaglio o che stai cavalcando vengono sempre disegnate. Effetto immediato, senza riavvio.", + "vesuvio.option.blockEntityCulling": "Culling dei blocchi speciali", + "vesuvio.option.blockEntityCulling.tooltip": "Non disegna bauli, cartelli, stendardi, scatole di shulker, teste e gli altri blocchi con entità che sono fuori dalla visuale o nascosti dietro blocchi pieni. I raggi dei fari, i varchi dell'End e i blocchi struttura vengono sempre disegnati. L'aspetto non cambia, ma uno che entra in vista da dietro un muro può comparire con un fotogramma di ritardo. Effetto immediato, senza riavvio.", + "vesuvio.option.particleCulling": "Culling delle particelle", + "vesuvio.option.particleCulling.tooltip": "Non disegna le particelle nei punti che il gioco sa già che non puoi vedere (lo stesso controllo che nasconde grotte e stanze dietro i muri). Le particelle fuori dalla visuale le salta già il gioco. Nessuna differenza visiva. Effetto immediato, senza riavvio.", + "vesuvio.option.signTextCulling": "Culling del testo dei cartelli", + "vesuvio.option.signTextCulling.tooltip": "Non disegna il testo sul lato di un cartello rivolto dall'altra parte (la scheda grafica lo scarterebbe comunque), e tutto il testo di un cartello quando ogni lettera è più piccola di un pixel alla dimensione della finestra e al campo visivo attuali. Nessuna differenza visiva. Senza riavvio.", + "vesuvio.option.itemFrameCulling": "Culling delle cornici", + "vesuvio.option.itemFrameCulling.tooltip": "Non disegna le cornici (con oggetto o mappa) quando sei dietro il muro a cui sono appese e ogni blocco di quel muro sulla linea di vista è pieno, e solo l'oggetto o la mappa quando è più piccolo di un pixel. Nessuna differenza visiva. Senza riavvio.", + "vesuvio.option.beaconBeamCulling": "Culling dei raggi dei fari", + "vesuvio.option.beaconBeamCulling.tooltip": "Il gioco disegna ogni raggio dei fari a ogni frame, anche alle tue spalle. Salta i raggi del tutto fuori dalla visuale (con margine per l'oscillazione della visuale e l'inclinazione quando subisci danni). Nessuna differenza visiva. Senza riavvio.", + "vesuvio.option.fastEntityModels": "Modelli delle entità più veloci", + "vesuvio.option.fastEntityModels.tooltip": "Mob, giocatori, bauli e altri modelli sono fatti di scatole. Il gioco mette al suo posto ogni angolo di una scatola tre volte (una per ogni faccia che lo usa); così lo fa una volta per angolo. Esattamente gli stessi vertici, bit per bit, quindi nessuna differenza visiva. Meno tempo di CPU con molti mob (farm, villaggi). Senza riavvio.", + "vesuvio.option.signTextCache": "Cache del testo dei cartelli", + "vesuvio.option.signTextCache.tooltip": "Il gioco misura e impagina di nuovo il testo di ogni cartello visibile a ogni frame. Così l'impaginazione di ogni riga viene tenuta e solo ridisegnata dove si trova il cartello. Stesse lettere, stesso posto, nessuna differenza visiva; ricostruita dopo un cambio di resource pack o di font. Il testo offuscato (magico) non viene mai tenuto. Aiuta in città e basi con molti cartelli. Senza riavvio.", + "vesuvio.option.animatedTextures": "Anima solo le texture visibili", + "vesuvio.option.animatedTextures.tooltip": "Le texture animate dei blocchi (acqua, lava, fuoco, portali, lanterne marine...) vengono ridisegnate solo mentre qualcosa sullo schermo le usa. Risparmia lavoro della GPU a ogni tick delle texture. L'aspetto non cambia: una texture che entra in vista riprende a muoversi entro un tick (1/20 di secondo). Ha pieno effetto sui chunk costruiti dopo averla attivata. Nessun riavvio.", + "vesuvio.option.idleFps": "Riduci gli FPS quando inattivo", + "vesuvio.option.idleFps.tooltip": "Quando la finestra del gioco non è in primo piano, gli FPS vengono limitati (vedi Limite FPS inattivo). Quando è ridotta a icona non si disegna quasi nulla (un fotogramma al secondo), ma il gioco continua a girare. Giocare con la finestra in primo piano non cambia mai. Nessun riavvio.", + "vesuvio.option.idleFpsLimit": "Limite FPS inattivo", + "vesuvio.option.idleFpsLimit.tooltip": "FPS mentre la finestra è aperta ma non in primo piano (da 3 a 60). Il gioco continua a girare a piena velocità. Nessun riavvio.", + "vesuvio.option.hiddenWindowFps": "Finestra nascosta come ridotta a icona", + "vesuvio.option.hiddenWindowFps.tooltip": "Quando la finestra del gioco è nascosta o coperta del tutto (su macOS: Cmd+H, un'altra Scrivania o altre finestre sopra tutta), non si disegna quasi nulla, come quando è ridotta a icona, ma il gioco continua a girare. Funziona solo dove il sistema lo comunica al gioco; altrimenti non cambia nulla. Senza riavvio.", + "vesuvio.option.pausedFps": "Limita gli FPS in pausa", + "vesuvio.option.pausedFps.tooltip": "In giocatore singolo, mentre il gioco è in pausa dietro un menu (Esc, opzioni), gli FPS vengono limitati al Limite FPS in pausa: il mondo è fermo, quindi più fotogrammi fanno solo calore e rumore della ventola. Senza riavvio.", + "vesuvio.option.pausedFpsLimit": "Limite FPS in pausa", + "vesuvio.option.pausedFpsLimit.tooltip": "FPS mentre il giocatore singolo è in pausa dietro un menu (da 10 a 240). Predefinito 60. Senza riavvio.", + "vesuvio.option.fastStartupCompile": "Compilazione shader più rapida (Mac)", + "vesuvio.option.fastStartupCompile.tooltip": "Solo macOS: chiede a MoltenVK di far compilare a Metal gli shader su più thread insieme, così il breve scatto quando un effetto o un blocco compare per la prima volta dura meno. L'aspetto non cambia. Richiede il riavvio. Su Windows e Linux non fa nulla.", + "vesuvio.option.pipelineCache": "Conserva shader compilati", + "vesuvio.option.pipelineCache.tooltip": "Salva gli shader compilati dal driver grafico (cache delle pipeline Vulkan) nella cartella vesuvio-cache quando esci e li riusa al prossimo avvio: il gioco parte più in fretta e la prima volta che vedi un nuovo mob, effetto o passaggio di shader non scatta più. Il file viene verificato con scheda e driver e scartato dopo un aggiornamento del driver. Il guadagno è maggiore su Windows; su Mac Metal ha già una sua cache, quindi è minore. Nessun cambiamento visivo. Richiede il riavvio.", + "vesuvio.option.asyncSubmit": "Invio asincrono (Mac, sperimentale)", + "vesuvio.option.asyncSubmit.tooltip": "Solo macOS: MoltenVK converte ogni fotogramma in Metal su un thread suo invece che su quello del gioco. Può alzare gli FPS quando il limite è la CPU; non fa nulla quando il limite è la GPU. Sperimentale: disattivalo se vedi sfarfallii, scatti o crash. L'aspetto non cambia. Richiede il riavvio. Su Windows e Linux non fa nulla.", + "vesuvio.option.metalArgumentBuffersOff": "Niente argument buffer Metal (Mac, esperimento)", + "vesuvio.option.metalArgumentBuffersOff.tooltip": "Solo macOS, un esperimento da misurare: dice a MoltenVK di non usare gli argument buffer di Metal, così texture e buffer di ogni disegno vengono collegati uno per uno invece che tramite un'unica tabella. Il gioco crea una tabella nuova quasi per ogni disegno, quindi può essere più veloce o più lento: esegui il benchmark di Vesuvio con l'opzione spenta e accesa e tieni quella che vince. Gli shader pack che usano molte texture insieme (più di 16 in uno shader) potrebbero non disegnare con l'opzione accesa; in quel caso spegnila. Per il resto l'aspetto non cambia. Spenta di base. Richiede il riavvio. Su Windows e Linux non fa nulla.", + "vesuvio.option.mergeTransferBarriers": "Unisci barriere di invio", + "vesuvio.option.mergeTransferBarriers.tooltip": "Dopo ogni copia verso la scheda grafica (chunk, uniform, vertici di entità e GUI, texture) il gioco fa fermare la GPU finché tutto quello che c'è prima non è finito. Le copie che toccano memoria diversa non ne hanno bisogno, quindi copie consecutive di questo tipo ora condividono una sola attesa, registrata prima di qualunque cosa possa usare i loro dati. Su Mac ogni attesa apre anche un nuovo gruppo di comandi Metal, quindi il caricamento dei chunk è più fluido. Stessa immagine. Senza riavvio.", + "vesuvio.option.framebufferCache": "Mantieni i framebuffer", + "vesuvio.option.framebufferCache.tooltip": "Ogni volta che il gioco inizia a disegnare in un gruppo di immagini crea un framebuffer Vulkan (l'elenco di quelle immagini) e lo butta via dopo il fotogramma, circa 20 volte per fotogramma e molte di più con gli shader pack. Ora ognuno viene tenuto e riusato finché si disegna di nuovo nelle stesse immagini, e buttato via quando lo è una delle sue immagini (ridimensionando la finestra, cambiando shader pack). Un po' meno tempo di CPU per fotogramma. Stessa immagine. Senza riavvio.", + "vesuvio.option.mappedVertexPools": "Scrittura diretta dei vertici", + "vesuvio.option.mappedVertexPools.tooltip": "Solo sui computer in cui la grafica condivide la memoria principale (Apple silicon e altre GPU integrate; verificato da quello che riporta il driver): i vertici di mob, blocchi-entità, particelle e GUI vengono scritti direttamente nella memoria da cui la scheda grafica li disegna, invece che in un buffer intermedio che la scheda grafica poi ricopia. Da due a quattro copie e attese della GPU in meno per fotogramma. Un buffer viene riscritto solo dopo che la scheda grafica ha finito ogni fotogramma che lo usava. Stessa immagine. Sugli altri computer non fa nulla. Senza riavvio.", + "vesuvio.option.gpuPassTimers": "Timer dei passaggi GPU", + "vesuvio.option.gpuPassTimers.tooltip": "Uno strumento di diagnosi: misura quanto tempo la scheda grafica passa su ogni passaggio di disegno del fotogramma (terreno, entità, nuvole, GUI, ogni passaggio di uno shader pack come ombre, acqua ed effetti finali di Etna), con timestamp della GPU scritti tra un passaggio e l'altro. Si vede su F3 (con la voce Vesuvio): il fotogramma intero e i cinque passaggi più lenti, in media sull'ultimo secondo. Il benchmark lo accende da solo per un passo e scrive una tabella completa in latest.log. Stessa immagine. Costa un po' di tempo della GPU quando è acceso, di più su Mac, quindi lascialo spento quando giochi. Senza riavvio.", + "vesuvio.option.gpuPassTimers.unsupported": "Non supportato da questo driver grafico: non fa nulla.", + "vesuvio.option.worldEntitySectionLookup": "Ricerca rapida delle entità", + "vesuvio.option.worldEntitySectionLookup.tooltip": "Quando il gioco cerca entità vicino a un punto (mob che si spingono, tramogge che raccolgono oggetti, mob che cercano un bersaglio), controlla solo le poche zone intorno a quel punto invece di scorrere un intero elenco ordinato. Stesse entità, stesso ordine: nessun cambiamento nel gioco o nella grafica. Funziona in giocatore singolo e per il tuo giocatore in multigiocatore. Senza riavvio.", + "vesuvio.option.worldRareEntityIndex": "Salta le entità contro cui non si urta", + "vesuvio.option.worldRareEntityIndex.tooltip": "Solo barche, carrelli da miniera, shulker e ghast felici bloccano chi ci urta, e solo i carrelli contenitore e le barche con baule possono rifornire una tramoggia. Ogni zona tiene un breve elenco di questi, così mob in movimento, oggetti a terra e tramogge ferme non controllano più ogni mucca e ogni oggetto intorno. Stessi risultati di vanilla: nessun cambiamento nel gioco. Molto utile in farm di mob e zone di raccolta oggetti. Richiede il riavvio.", + "vesuvio.option.worldPathTypeCache": "Ricorda i tipi di blocco per i percorsi", + "vesuvio.option.worldPathTypeCache.tooltip": "I mob che cercano un percorso chiedono \"che tipo di blocco è questo\" per centinaia di blocchi; la risposta dipende solo dal blocco, quindi viene calcolata una volta per tipo di blocco e ricordata (e dimenticata quando si ricaricano i data pack). Stessi percorsi di vanilla. Solo giocatore singolo (su un server i percorsi li calcola il server). Senza riavvio.", + "vesuvio.option.worldPoiVerticalRange": "Ricerche di villici e api più mirate", + "vesuvio.option.worldPoiVerticalRange.tooltip": "Villici che cercano letti e postazioni di lavoro, api che cercano l'alveare e la comparsa dei golem cercano in una sfera intorno a sé, ma vanilla prima legge tutte le altezze di ogni chunk. Qui si leggono solo le altezze che la sfera raggiunge. Stessi risultati, stesso ordine: nessun cambiamento nel gioco. Solo giocatore singolo. Senza riavvio.", + "vesuvio.option.worldFastSuffocationCheck": "Controllo soffocamento più rapido", + "vesuvio.option.worldFastSuffocationCheck.tooltip": "Ogni mob controlla a ogni tick se ha la testa dentro un blocco. Qui si fa lo stesso controllo senza gli oggetti in più che vanilla crea per farlo. Stesso risultato: nessun cambiamento nel gioco. Solo giocatore singolo. Senza riavvio.", + "vesuvio.option.chunkScheduler": "Costruzione chunk graduale", + "vesuvio.option.chunkScheduler.tooltip": "Dà ai thread che costruiscono i chunk solo le sezioni che riescono a lavorare in un frame, prima le più vicine, invece di copiare tutte insieme le sezioni cambiate in vista. Toglie i frame lunghi quando entri in un mondo, ti teletrasporti o voli su terreno nuovo, e i thread lavorano sempre su quello che hai davanti adesso. Le sezioni che modifichi e quelle entro circa 27 blocchi vengono costruite subito. Stessa immagine: una sezione in attesa continua a mostrare quello che mostrava e viene costruita qualche frame dopo. Senza riavvio.", + "vesuvio.option.skipEmptySections": "Salta sezioni vuote", + "vesuvio.option.skipEmptySections.tooltip": "Un pezzo di mondo 16x16x16 fatto solo d'aria (quasi tutto il cielo sopra il terreno) riceve subito il suo risultato vuoto, senza copiare il mondo intorno e senza un compito di costruzione. Vanilla li costruisce tutti, anche di nuovo dopo ogni cambio di luce. Stessa immagine. Senza riavvio.", + "vesuvio.option.trimSectionCopies": "Copie di sezione leggere", + "vesuvio.option.trimSectionCopies.tooltip": "Per costruire un pezzo di mondo il gioco copia quel pezzo e i suoi vicini; ogni copia prendeva anche una copia dell'intero elenco di bauli, cartelli e altri blocchi-entità del chunk. Ora ogni copia tiene solo i suoi. Meno spazzatura in memoria e meno pause nelle basi piene di magazzini. Stessa immagine. Senza riavvio.", + "vesuvio.option.chunkUploadBudget": "Tempo di invio chunk (ms)", + "vesuvio.option.chunkUploadBudget.tooltip": "Massimo di millisecondi per frame per mandare alla scheda grafica i chunk finiti. Vanilla li manda tutti nello stesso frame, e dopo l'ingresso in un mondo o in volo possono essere decine di megabyte: un frame lungo. Con un limite il resto va nei frame successivi; un chunk appare comunque solo quando è completo, quindi un chunk nuovo può comparire un frame o due dopo, mai a metà. Se i chunk continuano ad aspettare il limite cresce da solo, e dopo 10 frame viene mandato tutto insieme. 0 = nessun limite (vanilla). Predefinito 2. Senza riavvio.", + "vesuvio.option.mappedChunkStaging": "Chunk diretti in memoria grafica", + "vesuvio.option.mappedChunkStaging.tooltip": "I chunk finiti vengono scritti direttamente nella memoria che la scheda grafica può leggere, invece di essere copiati un'altra volta dalla CPU prima di ogni invio. Usa fino a circa 150 MB di memoria in più quando è attivo (l'area di 98 MB di vanilla resta riservata ma inutilizzata). Funziona insieme al tempo di invio chunk. Stessa immagine. Senza riavvio: vale dalla prossima volta che nessun chunk è in attesa di invio, di solito entro un frame.", + "vesuvio.option.compactTerrainVertices": "Vertici del terreno compatti", + "vesuvio.option.compactTerrainVertices.tooltip": "Memorizza i blocchi del mondo in 20 byte per vertice invece di 28: le posizioni come numeri a mezza precisione, le coordinate delle texture come numeri a 16 bit. Circa il 29% in meno di memoria grafica, banda di invio e lettura dei vertici per il terreno, utile soprattutto con le ombre di Etna, che rileggono ogni vertice dei blocchi. Funziona con Etna e con il renderer di Fabric API. I blocchi sulla solita griglia di un sedicesimo di blocco restano esattamente nella stessa posizione; superfici dell'acqua, piante e forme ruotate possono spostarsi fino a 1/128 di blocco, e le texture di una piccola frazione di texel, quindi l'immagine non è identica bit per bit a quella vanilla (per questo è disattivato di base). Con un atlante delle texture più grande di 8192 (resource pack enormi) dal riavvio successivo le coordinate delle texture restano float: 24 byte. Richiede il riavvio.", + "vesuvio.option.translucentSortThrottle": "Meno riordini di acqua/vetro", + "vesuvio.option.translucentSortThrottle.tooltip": "I blocchi trasparenti (acqua, vetro, ghiaccio) vengono riordinati dal più lontano al più vicino mentre ti muovi. Vanilla lo fa a ogni blocco percorso per le sezioni lontane allineate con te, per esempio un intero mare alla tua altezza. Ora una sezione lontana viene riordinata quando ti sei spostato abbastanza perché l'ordine cambi (ogni 2 blocchi a 64 blocchi di distanza, ogni 8 a 256): l'ordine non è mai più sbagliato di quanto vanilla accetti per una sezione a 32 blocchi. Le sezioni vicine sono riordinate come in vanilla. Non fa nulla con la Trasparenza migliorata. Senza riavvio.", + "vesuvio.option.clearFusion": "Pulizie unite al disegno", + "vesuvio.option.clearFusion.tooltip": "Quando il gioco pulisce un'immagine (con un colore o con profondità vuota) e poi ci disegna sopra, la pulizia ora avviene all'inizio del disegno invece che in un passaggio a parte che scrive tutta l'immagine e poi la rilegge. Le pulizie che nessuno può vedere (quelle della profondità dell'interfaccia) vengono saltate, e gli effetti di post-elaborazione non creano più buffer di profondità che non leggono mai. Aiuta soprattutto con gli shader, con la sfocatura dei menu e sulle GPU Apple. L'immagine non cambia. Senza riavvio." +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index d777d3e..5d29f92 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -15,7 +15,20 @@ ] }, "mixins": [ - "vesuvio.mixins.json" + "vesuvio.mixins.json", + "vesuvio.leaves.mixins.json", + "vesuvio.ui.mixins.json", + "vesuvio.memory.mixins.json", + "vesuvio.faces.mixins.json", + "vesuvio.entities.mixins.json", + "vesuvio.culling.mixins.json", + "vesuvio.frame.mixins.json", + "vesuvio.ticks.mixins.json", + "vesuvio.chunks.mixins.json", + "vesuvio.clears.mixins.json", + "vesuvio.draws.mixins.json", + "vesuvio.vertex.mixins.json", + "vesuvio.models.mixins.json" ], "depends": { "fabricloader": ">=0.19.5", diff --git a/src/main/resources/vesuvio.chunks.mixins.json b/src/main/resources/vesuvio.chunks.mixins.json new file mode 100644 index 0000000..9fd1175 --- /dev/null +++ b/src/main/resources/vesuvio.chunks.mixins.json @@ -0,0 +1,24 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.chunks.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "CompileTaskStartMixin", + "ExtractSchedulingMixin", + "RenderSectionInvoker", + "RenderSectionStateMixin", + "RenderedAllMixin", + "ResortThrottleMixin", + "SectionCopyTrimMixin", + "StagedEntryAccessor", + "StagingBufferAccessor", + "StagingCpuMixin", + "StagingMappedMixin", + "UberBufferBudgetMixin", + "UploadBudgetMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.clears.mixins.json b/src/main/resources/vesuvio.clears.mixins.json new file mode 100644 index 0000000..39c1668 --- /dev/null +++ b/src/main/resources/vesuvio.clears.mixins.json @@ -0,0 +1,21 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.clears.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "ClearsFrontendEncoderMixin", + "ClearsFrontendRenderPassMixin", + "ClearsFrontendSurfaceMixin", + "ClearsGameRendererMixin", + "ClearsGuiRendererMixin", + "ClearsPostChainMixin", + "ClearsPostPassAccessor", + "ClearsVulkanEncoderAccessor", + "ClearsVulkanEncoderMixin", + "ClearsVulkanTextureMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.culling.mixins.json b/src/main/resources/vesuvio.culling.mixins.json new file mode 100644 index 0000000..0fbcbe2 --- /dev/null +++ b/src/main/resources/vesuvio.culling.mixins.json @@ -0,0 +1,21 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.culling.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "CullEntityRendererInvoker", + "CullEntityVisibilityMixin", + "CullLevelExtractorMixin", + "CullLevelRendererMixin", + "CullSectionMeshMixin", + "FogParticleGroupMixin", + "FogRendererAccessor", + "ItemFrameItemMixin", + "ParticleAccessor", + "SignTextMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.draws.mixins.json b/src/main/resources/vesuvio.draws.mixins.json new file mode 100644 index 0000000..279d1b8 --- /dev/null +++ b/src/main/resources/vesuvio.draws.mixins.json @@ -0,0 +1,13 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.draws.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "VulkanCommandEncoderMixin", + "VulkanRenderPassMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.entities.mixins.json b/src/main/resources/vesuvio.entities.mixins.json new file mode 100644 index 0000000..9584e49 --- /dev/null +++ b/src/main/resources/vesuvio.entities.mixins.json @@ -0,0 +1,17 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.entities.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "BlockEntityCullStateMixin", + "EntityCullStateMixin", + "EntityRendererInvoker", + "FrustumInvoker", + "LevelExtractorMixin", + "QuadParticleGroupMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.faces.mixins.json b/src/main/resources/vesuvio.faces.mixins.json new file mode 100644 index 0000000..5afc658 --- /dev/null +++ b/src/main/resources/vesuvio.faces.mixins.json @@ -0,0 +1,17 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.faces.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "CompiledSectionMeshMixin", + "LevelRendererDrawCacheMixin", + "LevelRendererFacesMixin", + "SectionCompilerMixin", + "SectionCompilerResultsMixin", + "UberAllocationsMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.frame.mixins.json b/src/main/resources/vesuvio.frame.mixins.json new file mode 100644 index 0000000..7a2a660 --- /dev/null +++ b/src/main/resources/vesuvio.frame.mixins.json @@ -0,0 +1,42 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.frame.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "FrameAnimationStateMixin", + "FrameAtlasManagerMixin", + "FrameAtlasSpriteMixin", + "FrameBlockModelFeatureRendererMixin", + "FrameCompiledSectionMeshMixin", + "FrameFramebufferCacheMixin", + "FrameFramerateLimitTrackerMixin", + "FrameGpuTimersEncoderMixin", + "FrameGpuTimersStagingMixin", + "FrameItemFeatureRendererMixin", + "FrameLevelRendererMixin", + "FrameMinecraftMixin", + "FrameMovingBlockFeatureRendererMixin", + "FrameMultiPartModelMixin", + "FramePipelineCacheCompileMixin", + "FramePipelineCacheDeviceMixin", + "FrameSectionCompilerMixin", + "FrameSectionResultsMixin", + "FrameSingleVariantMixin", + "FrameSpriteContentsMixin", + "FrameStagedDrawAccessor", + "FrameStagedVertexBufferInvoker", + "FrameStagedVertexBufferMixin", + "FrameTextureViewCloseMixin", + "FrameTextureAtlasMixin", + "FrameTransferBarrierMixin", + "FrameUnifiedMemoryMixin", + "FrameVertexCountersMixin", + "FrameVertexPoolMixin", + "FrameVulkanInstanceMixin", + "FrameWeightedVariantsMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.leaves.mixins.json b/src/main/resources/vesuvio.leaves.mixins.json new file mode 100644 index 0000000..b77983c --- /dev/null +++ b/src/main/resources/vesuvio.leaves.mixins.json @@ -0,0 +1,15 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.leaves.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "LeafBlockChangeMixin", + "LeafCompileMixin", + "LeafFaceCullMixin", + "LeafSettingPollMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.memory.mixins.json b/src/main/resources/vesuvio.memory.mixins.json new file mode 100644 index 0000000..59d170e --- /dev/null +++ b/src/main/resources/vesuvio.memory.mixins.json @@ -0,0 +1,22 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.memory.mixin", + "plugin": "it.cosimo.vesuvio.memory.MemoryMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "BitSetDiscreteVoxelShapeAccessor", + "BlockShapeTableMixin", + "BlockStateFaceTableMixin", + "BlockStateOcclusionShapeMixin", + "CrossCollisionBlockMixin", + "IdentifierMixin", + "PalettedContainerMixin", + "StateCacheShapeMixin", + "StateCacheSupportMixin", + "VoxelShapeAccessor", + "WallBlockMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.mixins.json b/src/main/resources/vesuvio.mixins.json index 53cbad8..485495a 100644 --- a/src/main/resources/vesuvio.mixins.json +++ b/src/main/resources/vesuvio.mixins.json @@ -4,6 +4,7 @@ "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", "compatibilityLevel": "JAVA_25", "client": [ + "LevelChangeMixin" ], "injectors": { "defaultRequire": 1 diff --git a/src/main/resources/vesuvio.models.mixins.json b/src/main/resources/vesuvio.models.mixins.json new file mode 100644 index 0000000..417c249 --- /dev/null +++ b/src/main/resources/vesuvio.models.mixins.json @@ -0,0 +1,17 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.models.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "FontSetGenerationMixin", + "ModelCubeMixin", + "ModelsFrameMixin", + "SignTextCacheMixin", + "SignTextLinesMixin", + "TextFeatureCacheMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.ticks.mixins.json b/src/main/resources/vesuvio.ticks.mixins.json new file mode 100644 index 0000000..fb06481 --- /dev/null +++ b/src/main/resources/vesuvio.ticks.mixins.json @@ -0,0 +1,22 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.ticks.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "mixins": [ + "BlockStatePathTypeMixin", + "EntitySectionMixin", + "EntitySectionStorageMixin", + "EntitySuffocationMixin", + "HopperBlockEntityMixin", + "LevelEntityGetterAdapterAccessor", + "LevelMixin", + "MappedRegistryMixin", + "PoiManagerMixin", + "SectionStorageAccess", + "WalkNodeEvaluatorMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.ui.mixins.json b/src/main/resources/vesuvio.ui.mixins.json new file mode 100644 index 0000000..c07673b --- /dev/null +++ b/src/main/resources/vesuvio.ui.mixins.json @@ -0,0 +1,18 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.ui.mixin", + "plugin": "it.cosimo.vesuvio.VesuvioMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "DebugScreenEntriesMixin", + "DebugScreenEntryListMixin", + "InputUiMixin", + "MinecraftUiMixin", + "MouseUiMixin", + "OptionsKeyMixin", + "VideoSettingsScreenMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/vesuvio.vertex.mixins.json b/src/main/resources/vesuvio.vertex.mixins.json new file mode 100644 index 0000000..3e2a882 --- /dev/null +++ b/src/main/resources/vesuvio.vertex.mixins.json @@ -0,0 +1,15 @@ +{ + "required": true, + "package": "it.cosimo.vesuvio.vertex.mixin", + "plugin": "it.cosimo.vesuvio.vertex.VertexMixinPlugin", + "compatibilityLevel": "JAVA_25", + "client": [ + "BufferBuilderCompactMixin", + "MeshDataCompactMixin", + "RenderPipelinesCompactMixin", + "TextureAtlasCompactMixin" + ], + "injectors": { + "defaultRequire": 1 + } +}