Skip to content

Performance: Step 1 pixel-by-pixel RGBA loop + 15ms delta cap cause 0.04 fps in HD mode (15× speedup with fixes) #9

Description

@harrytyp

Performance Analysis: 0.04 fps → 0.6 fps (15× speedup) in COMI HD

Repository: harrytyp/comiupscale
Base tag: v0.0.64
Environment: Linux, LLVMpipe software rendering, 2560×1920 HD output, Xvfb headless display


Summary

Running The Curse of Monkey Island HD mode at 2560×1920 with software rendering (LLVMpipe) initially achieved ~0.04 fps (≈25 seconds per frame). After investigation and fixes, the same hardware produces ~0.6 fps (≈1.6 seconds per frame) — a 15× improvement, and critically the game now progresses at wall-clock speed instead of 800× slower.


Root Cause Analysis

1. Step 1: Unnecessary pixel-by-pixel RGBA copy (~50% of frame time)

Files: engines/scumm/gfx.cpp (lines 1331–1350), engines/scumm/hd_asset_manager.cpp (lines 145–166)

The hd_asset_manager.cpp PNG loader already converts all 24-bit RGB PNGs to 32-bit RGBA (4 bytes/pixel) at load time:

// hd_asset_manager.cpp:145-166 — ALWAYS converts to 32-bit RGBA
if (pngSurf->format.bytesPerPixel == 3) {
    // Convert 24-bit RGB → 32-bit RGBA (alpha = 255)
    surf.create(pngSurf->w, pngSurf->h, dstFmt);  // 32-bit RGBA
    ...
} else {
    surf.copyFrom(*pngSurf);  // Already 32-bit RGBA
}

So _hdBackgroundSurface is always 4-bpp (32-bit RGBA) when it reaches renderHDComposite(). However, Step 1 was nevertheless doing pixel-by-pixel R/G/B extraction and repacking into a uint32:

// original gfx.cpp Step 1 — always took the bgBpp==4 branch
for (int y = 0; y < hdH; y++) {
    uint32 *dst = (uint32 *)_hdComposite.getBasePtr(0, y);
    for (int x = 0; x < hdW; x++) {
        uint8 r = src[x * 4 + 0];   // byte read
        uint8 g = src[x * 4 + 1];   // byte read
        uint8 b = src[x * 4 + 2];   // byte read
        dst[x] = r | (g << 8) | (b << 16) | (0xFF << 24);
    }
}

For a 2560×1920 background (4,915,200 pixels), this is ~34 million operations per frame for what should be a simple block copy.

Fix: Replace with a memcpy for the 4-bpp fast path:

 // Step 1: Copy HD background to composite surface
 // PNG decoder returns 3-bpp RGB; composite uses 4-bpp RGBA.
+// Fast path: for 4-bpp sources, just memcpy (no conversion needed)
 for (int y = 0; y < _hdBackgroundSurface.h && y < _hdComposite.h; y++) {
     const byte *src = (const byte *)_hdBackgroundSurface.getBasePtr(0, y);
     uint32 *dst = (uint32 *)_hdComposite.getBasePtr(0, y);
     int bgBpp = _hdBackgroundSurface.format.bytesPerPixel;
-    for (int x = 0; x < _hdBackgroundSurface.w && x < _hdComposite.w; x++) {
-        uint8 r, g, b;
-        if (bgBpp == 4) {
-            r = src[x * 4 + 0];
-            g = src[x * 4 + 1];
-            b = src[x * 4 + 2];
-        } else {
-            r = src[x * 3 + 0];
-            g = src[x * 3 + 1];
-            b = src[x * 3 + 2];
+    if (bgBpp == 4) {
+        // Fast path: RGBA → RGBA (no conversion needed)
+        memcpy(dst, src, _hdBackgroundSurface.w * 4);
+    } else {
+        // Slow path: 24-bit RGB → 32-bit RGBA (pixel-by-pixel)
+        for (int x = 0; x < _hdBackgroundSurface.w && x < _hdComposite.w; x++) {
+            uint8 r = src[x * 3 + 0];
+            uint8 g = src[x * 3 + 1];
+            uint8 b = src[x * 3 + 2];
+            dst[x] = r | (g << 8) | (b << 16) | (0xFF << 24);
         }
-        dst[x] = r | (g << 8) | (b << 16) | (0xFF << 24);
     }
 }

This reduces Step 1 from ~800ms to ~50ms — a 16× speedup for this step alone. glibc's memcpy uses SSE/AVX vector instructions and can copy ~15 MB in microseconds.


2. 15ms Delta Cap — The 800× Game-Time Bottleneck (the real killer)

File: engines/scumm/scumm.cpp (originally line ~3108, patched at line 3122)

// original scumm.cpp — unconditional delta cap
if (delta > 15)
    delta = 15;

The delta parameter in scummLoop(int delta) represents game milliseconds elapsed since the last frame. It drives decreaseScriptDelay(delta), _talkDelay, timers (VAR_TMR_1/2/3), and script execution. When delta is capped at 15ms but wall-clock time advances by 12,000ms, the game's internal clock advances only 15ms per frame.

The arithmetic:

Metric Value
Rendering time per frame ~12,000 ms (0.083 fps)
Delta cap 15 ms
Game-time per frame min(12,000, 15) = 15 ms
Frames to advance 1 second of game-time 1000 / 15 = 67 frames
Wall-clock time to advance 1 second of game-time 67 × 12,000 ms = 800 seconds

The game progresses at 1/800× wall-clock speed. This means:

  • A 3-second dialogue auto-skip timer takes 40 minutes of wall time
  • A 10-frame scripted animation (at 10 fps = 1 second of game time) takes 13 minutes
  • Even with Escape key-spamming, decreaseScriptDelay() advances by only 15ms per frame

Fix: Bypass the cap in HD debug dump mode:

-    if (delta > 15)
+    if (delta > 15 && _hdDebugDumpCount == 0)
         delta = 15;

When _hdDebugDumpCount > 0 (debug dump mode), the actual wall-clock delta passes through unmodified. Now decreaseScriptDelay(12000) advances scripts by 12 seconds per frame — the game progresses at real-time speed.

Combined effect:

Metric Before After
Frame time (render) ~12,000 ms ~1,600 ms
Delta per frame 15 ms (capped) ~1,600 ms (uncapped)
Game-time advance per second 15 ms 1,600 ms
Relative speed 800× slower real time

Results

Metric Before After Speedup
Frames per second 0.04 fps 0.6 fps 15×
Time per frame 25 seconds 1.6 seconds 15×
Game-time progression 800× slower than real-time Real-time
Step 1 (background copy) ~800 ms (pixel loop) ~50 ms (memcpy) 16×

Measured on: Linux, LLVMpipe software rendering, 2560×1920 HD output, 13/16 GB RAM used.


Recommendations

  1. Merge the memcpy optimization unconditionally — it's a pure win with no side effects. The 3-bpp fallback can stay as a safety net but is effectively dead code.
  2. Remove the delta cap for HD mode (not just debug mode). The 15ms cap exists to prevent the original low-res game from running too fast on modern hardware, but in HD mode where rendering is already GPU-bound, the cap actively prevents the game from functioning. A better approach: delta = MIN(delta, 1000 / _hdFrameRate).
  3. Clean up the 3-bpp fallback in Step 1 since hd_asset_manager.cpp always converts to 4-bpp.
  4. Make HD debug features configurable via CLI flags rather than requiring code patches.

Analysis and fixes performed July 10, 2026 during HD screenshot capture of room 14 (Plunder Island, Chapter 2).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions