Skip to content

Implement draw cache for optimized rendering in TIC-80 - #2975

Open
Wang-Yue wants to merge 1 commit into
nesbox:mainfrom
Wang-Yue:pr/draw-cache
Open

Implement draw cache for optimized rendering in TIC-80#2975
Wang-Yue wants to merge 1 commit into
nesbox:mainfrom
Wang-Yue:pr/draw-cache

Conversation

@Wang-Yue

@Wang-Yue Wang-Yue commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

TIC-80 Draw Cache System

Rationale

In TIC-80, the main loop or editor tick function (tic()) is called 60 times per second, and by default, every single frame is fully redrawn from scratch, even when the screen contents are completely static (for example, when simply reading code, hovering a stationary mouse, or resting on a menu).

Furthermore, many games do not change their state or visual representation on every frame, and some games may only need to update and redraw their screen every 5, 10, or more frames.

Redrawing static frames or slow-running games continuously results in unnecessary and wasteful CPU consumption. The Draw Cache is an optimization layer designed to solve this by bypassing expensive, pixel-by-pixel rendering operations (like drawing text characters or rendering tilemaps) when the visual state of the application has not changed from the previous frame.


How It Works

1. Interception (Hooking)

All standard drawing APIs called by the TIC-80 studio/editors (e.g. tic_api_cls, tic_api_print, tic_api_rect, etc.) are intercepted in studio.h and routed through caching wrappers defined in draw_cache.c:

// Example: Intercepting `rect` drawing
static void cache_api_rect(tic_mem* tic, s32 x, s32 y, s32 w, s32 h, u8 color)
{
    tic_core* core = (tic_core*)tic;
    DrawCallRect call = { .type = DRAW_CALL_RECT, .x = x, .y = y, .w = w, .h = h, .color = color };
    
    // If the draw cache says we can skip it, do not call the actual drawing function
    if (handle_draw_call(core, &call, sizeof(call)))
    {
        tic_api_rect(tic, x, y, w, h, color);
    }
}

2. State Validation (Start of Frame)

At the beginning of each frame (tic_core_draw_cache_start), the cache checks whether the environment is unchanged compared to the previous frame. It compares:

  • RAM Block A (VRAM, tiles, sprites, map)
  • RAM Block B (sprite flags, fonts, button mapping)
  • VBANK1 contents
  • Mouse position and button states

If any of these inputs have changed, the draw cache is immediately invalidated for this frame, forcing a full redraw.


3. Record and Match (handle_draw_call)

During the update loop, as drawing commands are called, each command parameters are serialized into a DrawCall struct and passed to handle_draw_call:

  1. Recording: The command is appended to the current frame's draw call buffer (curr_calls_buf).
  2. Matching: If the cache is still valid, the new command is compared byte-by-byte with the command at the corresponding position in the previous frame's draw call buffer (prev_calls_buf).
    • Match: handle_draw_call returns false (skip execution). The drawing function is bypassed entirely.
    • Mismatch: The cache is instantly invalidated. handle_draw_call returns true (execute drawing). All subsequent drawing commands for the rest of the frame will be executed.

4. Finalization (End of Frame)

At the end of the frame (tic_core_draw_cache_end):

  • If the cache remained valid (all draw calls matched and inputs were identical):
    • No new commands were executed.
    • The final output screen pixels are guaranteed to be identical to the previous frame.
    • The renderer bypasses pushing a new frame to the GPU, saving both CPU and GPU rendering cycles.
  • If the cache was invalidated:
    • The new drawing commands were executed and recorded into curr_calls_buf.
    • The contents of curr_calls_buf are swapped to become prev_calls_buf for the next frame.
    • The current RAM and input states are saved to act as the reference for the next frame's start-of-frame validation.

Performance Benefits

  • Static Screens: In terminal or editor (code, music, etc) when no code is typed, CPU/GPU rendering overhead drops to nearly 0% active time.
  • Games: Tested 8 bit panda and Cauliflower Power. When the game frame is not changing rapidly (when the actor stand still), we only see low single digit (3-5%) CPU consumption, compared to ~20% previously using the new Apple backend.
  • Highly Dynamic Screens: Verified rendering. The CPU consumption does not see measurable increase as the software renderer (draw.c) is the main bottleneck and compare to that the cache operation is very cheap.

@joshgoebel

Copy link
Copy Markdown
Collaborator

Record and Match (handle_draw_call)

This (at first blush) sounds overengineered... IF we're already comparing ALL the VRAM, VBANK1, sprites, fonts, etc... shouldn't we have reached certainty about whether the screen could be different or not? Why do we also need to walk a history of draw commands? Sounds like a lot of extra complexity.

It's also possible the draw calls could have changed while the display didn't change at all.. Yes, edge cases for sure... but...

Curious what this part of the system is trying to account for?

@Wang-Yue

Copy link
Copy Markdown
Contributor Author

IF we're already comparing ALL the VRAM, VBANK1, sprites, fonts, etc... shouldn't we have reached certainty about whether the screen could be different or not? Why do we also need to walk a history of draw commands? Sounds like a lot of extra complexity.

If you profile the application, in the current system, the software renderer draw.c is the main bottleneck (accounts for > 70% of the total run time). The GPU rendering part, especially after moving to Metal, is fairly cheap, as it's just moving the software rendered pixel buffers to GPU. Even user program running in byte code accounts for a much smaller part of the run time for most games I tested. Comparing the ram and buffers in every cycle could help you skip the GPU rendering for that frame, but CPU still does all the software rendering at 60fps and you can't skip it unless comparing the command history.

Moving the software renderer to GPU is infeasible, due to retro games peek and poke all the time. I actually tried to port the entire software renderer to Metal but due to every time it peek/poke I have to copy the buffer back from GPU to CPU, the metal based renderer runs even slower than software one, for almost all games I tested.

So draw cache aim to solve this bottleneck in the software renderer.

Curious what this part of the system is trying to account for?

the major customer that will enjoy this optimization is all built in editors (code, music, sprite, etc). you will see CPU utilization drop to around 1/25. There are also games don't need 60fps rendering. for instance, my favorite game Cauliflower Power. When you are tackling the puzzle game and the character is standing still, you will see it only redraws 1 frame per second. The various slow games such as puzzle games, large portions of cartridges in Tech Tools Music sections from the official site, will all benefit from this tremendously.

@joshgoebel

Copy link
Copy Markdown
Collaborator

but CPU still does all the software rendering at 60fps

Why does it do this if no VRAM or registers have changed? Could we just fix the software rendering to be a bit smarter in terms of changes from frame to frame? If there were truly no changes to display then I'd think the software renderer could essentially be a NOOP, no?

@Wang-Yue

Copy link
Copy Markdown
Contributor Author

Why does it do this if no VRAM or registers have changed?

because VRAM is the final state of the software rendering. In retro console, user function (TIC()) will call draw function to redraw every frame. Without draw cache, you have to do the redraw from start to finish in order to find if VRAM changes or not. With draw cache you can compare the drawing command history and tell if we need to redraw or not.

Once at the stage of comparing VRAMS, the software rendering is already finished --- You cannot rewind the clock to skip that step when you find this frame's rendering is not needed.

Could we just fix the software rendering to be a bit smarter in terms of changes from frame to frame? If there were truly no changes to display then I'd think the software renderer could essentially be a NOOP, no?

The draw cache is fixing exactly this! When nothing needs to be redrawn, all drawing instructions becomes noops.

@Wang-Yue

Copy link
Copy Markdown
Contributor Author

I recorded a few performance graph for reviewers to understand the benefit of draw cache for quite games and code editing in this comment: #2973 (comment) please take a look

I want to post one additional performance graph here. It's recorded when playing stele, a action game where each single frame is different.

Screenshot 2026-07-28 at 12 50 13

so the user program calls map() on the lua side, which consumes 20% of the run time. the lua callback calls our cache_api_map function, which quickly cause cache miss, then, the cached system invalidated the cache and calls tic_api_map for software rendering.

On the graph you can find tic_api_map and cache_api_map takes almost exactly same time --- maybe there is only 0.1% difference but it's so hard to see. This proofs that even when every single frame needs to be redrawn, our cache has almost zero overhead, compared to the software rendering cycle.

the conclusion is: For dynamic screens, the system has non-measurable overhead. For close to static screens, the system saves 10-25X amount of CPU cycles.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants