[OpenJX Devlog #2] The solution for rendering with high FPS supported 4K monitor

In progress…

Where it stands

A whole village at 3840x2064, holding 161 FPS — 1980 ground tiles, 299 cover objects and 499 scenery pieces on screen, 45 of 121 regions resident, worst frame 8.4 ms and no dropped frames.

OpenJX at 4K: a full village of tiled-roof houses, a gate tower, a paved square and dense trees, with the debug overlay reporting 161 FPS over 1980 tiles, 299 objects and 499 scenery pieces

What got it there:

  • VSync is a config switch now. Present(1, 0) was hardcoded, so the frame rate could never exceed the monitor. It is a flag on the renderer, and the 60 FPS ceiling in devlog #1 was just that call.
  • Ground tiles are flattened and sorted once. The draw loop used to nest region → tile, re-walking every resident region each frame. The tiles are now collected into one flat array when the region set changes, sorted by sprite then frame, so a frame draws them in texture order instead of jumping between textures. Region culling happens once per region, not once per tile.
  • Streaming got adjustable. The load radius can change at runtime, and a region can be read from a prebuilt region file instead of being re-parsed out of the original data on every load.

Most of the working tree is not rendering at all, though: it is a content layer sitting between the game and the original files — a catalog with pluggable providers, the legacy format behind one of them, an import tool that bakes the original data into a package, and a config that names a map and an actor by id instead of listing sprite paths by hand. That deserves its own post.

Batching the draws

The same village, same 4K window: 865 FPS, worst frame 1.5 ms. From ~160 to over 800 in one commit.

OpenJX at 4K after batching: the same village scene, debug overlay reporting 865 FPS with a worst frame of 1.5 ms over 1952 tiles, 302 objects and 496 scenery pieces

Two things changed.

The renderer is SDL3 now. The hand-written D3D11 backend is gone — 644 lines deleted for 436, and the Win32 windowing code shrank with it. Less of my code to be wrong about, and vsync and render targets come for free.

Draws are regrouped by texture. This is the actual win. The scene is drawn in depth order, which means the texture changes on almost every draw call — tile, tree, tile, roof, tile. The renderer now walks the draw list and, for each item, looks ahead up to 48 entries for one using the same texture as the previous draw. If nothing between the two overlaps it on screen, it gets pulled forward. Non-overlapping sprites can be reordered freely — depth only matters between things that actually cover each other — so the visual result is identical while consecutive draws share a texture far more often.

There is also a render scale knob (RenderScalePercent in the ini): the scene renders into an offscreen target at a fraction of the window size and gets upscaled with nearest-neighbour on the way out. It is off by default — the numbers above are at full 4K — but it is there for machines that need it.

The setting the game will actually run at

Render scale at 200%: the scene renders at 1202x930 and is upscaled to fill a 2404x1860 window. The art was drawn for screens a fraction of the size of a modern one, so at native resolution everything is tiny — the character in the shots above is a speck. At 2x the sprites are the size they were meant to be, and the camera frames a scene instead of a map.

OpenJX at 200% render scale: the village square, its gate and paifang at twice the size with the character clearly visible, debug overlay reporting 2049 FPS with a worst frame of 0.9 ms

2049 FPS, worst frame 0.9 ms, with 300 ground tiles, 77 objects and 67 scenery pieces on screen and 12 of 49 regions resident. That is far less to draw than the 1952 / 302 / 496 of the 4K shots above — but this window is smaller as well as scaled, so the two are not a like-for-like measurement of the same view.

This is how the game will actually be played, so it is the number to measure from here on. Everything after this devlog is benchmarked at 200%.