Skip to content

Smooth unit animations with 32 poses, HD sprites and optional blur - #201

Open
genixpro wants to merge 13 commits into
masterfrom
codex/unit-animation-32-frames
Open

Smooth unit animations with 32 poses, HD sprites and optional blur#201
genixpro wants to merge 13 commits into
masterfrom
codex/unit-animation-32-frames

Conversation

@genixpro

@genixpro genixpro commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

This change makes unit animations smoother, particularly when units move slowly. Eight poses per direction produce visibly stepped motion; 32 poses provide finer sampling, and full-frame shutter blending makes skipped poses contribute at faster unit speeds. Normal rendering remains 25 FPS with the same movement and animation cycle duration.

All seven core sets use 32 poses per direction: explorer flight; worker walk, swim and harvest/build; warrior walk, swim and fight. There are 1,792 logical frames across 2,816 PNG layers. Native sprites retain their original dimensions; a second complete texture set is rendered at 4× width and height for the merged HD renderer and zoom controls.

Review follow-up: cold-cache work

Rebased onto master at 84a9b8b60, preserving the merged entering-unit draw-origin fix and the new torus settings alongside motion blur. Native/HD assets and original Blender files are unchanged by this follow-up.

World-unit drawing now shares a soft 2 ms composite-generation budget per presented frame, including initial texture upload. Cache hits remain available; deferred misses use the current sharp native pose with transient team coloring until requested composites are ready. There is no additional fallback cache or retention cap. A single composite may exceed the budget. Transparent pixels are skipped during compositing.

A synthetic cold-cache test of 300 fast workers over 128 frames measured first/p95/max sprite times of 260.7/680.8/1117.8 ms with unlimited generation, versus 47.1/41.3/47.7 ms with the budget. The caches held 3,072 versus 128 composites afterward: work is deferred, not eliminated. These local results do not guarantee a busy saved game will meet its frame deadline.

PR #234 separately avoids repeated artwork loading and preserves artwork across matches. It remains a separate change. Material/rig redesign and consistent zoomed UI styling are separate artwork/UI follow-ups; decoupled rendering remains the planned engine follow-up.

Rendering and controls

  • Add full-frame shutter motion blur to world units, disabled by default. Existing explicit preferences are preserved. The main General Settings checkbox saves the choice; Cancel restores it. F8 toggles the same saved setting during gameplay.
  • Cache final team-colored composites on demand, keyed by color and exact frame/alpha sequence. Cache hits draw one surface. Entries are unbounded while artwork is unchanged and clear when the Sprite is released or its texture pack reloads; the source recolor maps are cleared rather than retained as a second cache. Sharp world frames share this final-image cache, while sharp UI draws use one transient recolor surface.
  • Add 0.75x, 0.5x and 0.25x to the existing speed controls using 53, 80 and 160 ms ticks. Each draws once per tick; 0.75x is approximately 0.755x because the delay is integral milliseconds. Existing speed IDs and timings remain unchanged.
  • Keep legacy action bases and serialized data unchanged; translate frame indices only at rendering. Direction-8 turning, portraits, editor previews, offscreen indicators and credits retain their sharp rendering and existing cadence.
  • Preserve the original Blender sources, unit icons, movement positions, attack timing, save/replay formats and render-loop architecture. Keep all native sprites alongside the production HD pack.

The shutter follows the review's alpha-over sketch. It estimates the interval from current action speed and render interval, wraps only within the current action/direction, and does not store previous action/direction history. It is an approximate exposure: overlapping silhouettes and shadows retain alpha-over behavior. Cache generation uses premultiplied accumulation to allow drawing over any background; software output avoids the repeated rounding/darkening of legacy blits.

High-resolution artwork

Rebased onto the merged HD renderer and organized-artwork changes (87bdee0fd). All seven animation sets now have 128×128, 152×152 or 160×160 textures, rendered from the same original rigs at the same 32 poses. This improves detail when zoomed in; it preserves logical sprite dimensions, movement and normal 25 FPS rendering.

  • Preserve native unit sprites and icons in data/gfx, and the untouched Blender sources in datasrc/gfx/originals/units.
  • Retain approved HD layers in datasrc/gfx/production/original-derived; package them into data/highres/v1 with the established artwork pipeline. Preserve the existing 60 world frames.
  • Use four Blender workers under a combined four-CPU container limit on the eight-logical-CPU Mac. Scene preparation, render settings, layer hashes and packaging are reproducible; temporary scenes, downloaded tools and previews stay outside production folders.
  • Integrate final-image caching with HD textures and logical draw dimensions. A shutter uses one resolution throughout; incomplete HD coverage falls back to native for the whole composite. The classic artwork setting and software backend retain native textures.
Native and HD sample poses at equal display size — all seven sets

Matching team-color layers are enlarged equally by the browser to compare texture detail. The complete animations retain their separate shadow layers. Images link directly to the committed assets; no review-image files are added to the branch.

AnimationNative texture4× texture
Explorer flightNative Explorer flightHD Explorer flight
Worker walkNative Worker walkHD Worker walk
Worker swimNative Worker swimHD Worker swim
Worker harvest/buildNative Worker harvest/buildHD Worker harvest/build
Warrior walkNative Warrior walkHD Warrior walk
Warrior swimNative Warrior swimHD Warrior swim
Warrior fightNative Warrior fightHD Warrior fight

Before and after

These slowed comparisons show eight versus 32 poses without motion blur. Both sides have the same cycle duration. They illustrate pose sampling, not a change to the normal 25 FPS display cadence. Review images are attached to GitHub and excluded from the branch history.

Worker walking

Slowed worker walk: original eight poses versus new 32 poses

Explorer flight

Explorer flight: original eight poses versus new 32 poses

Worker swimming

Worker swimming: original eight poses versus new 32 poses

Worker harvesting / building

Worker harvesting / building: original eight poses versus new 32 poses

Warrior walking

Warrior walking: original eight poses versus new 32 poses

Warrior swimming

Warrior swimming: original eight poses versus new 32 poses

Warrior fighting

Warrior fighting: original eight poses versus new 32 poses

At equal points in the cycle, the old animation holds each pose while the new animation supplies intermediate poses:

Equal-time sprite samples showing the added intermediate poses

Matching original poses across all seven animation sets

Each pair shows the shipped pose on the left and the matching new pose on the right. The small shading differences are quantified below.

Before-and-after matching poses for all seven animation sets

Rendering fidelity

The original Blender 2.34 renderer was used after 2.79b failed to reproduce the original projection and warrior surface. Source rigs, animation channels, materials, lighting and layer separation are preserved. Native sprite dimensions stay unchanged; the additional HD renders multiply only the texture dimensions. Explorer requires a half-pixel camera alignment in the temporary scene copy.

Every fourth new pose was compared against the shipped sprites, across all eight directions and separate layers:

Set Pixel-identical comparisons Mean RGBA error, 0–255
Explorer flight 0/64 1.1101
Worker walk 64/128 0.2118
Worker swim 64/64 0
Worker harvest/build 128/128 0
Warrior walk 128/128 0
Warrior swim 64/64 0
Warrior fight 128/128 0

The residual differences are explorer shading/edge pixels and worker-walk shading. Worker-walk alpha and shadows match exactly. Overall, transparency matches in 682/704 comparisons. No redesigned motion was substituted. Comparison sheets were reviewed for original poses and loop boundaries; native framing retains the edge contact already present in explorer flight and warrior attacks.

Validation

The 32-pose asset validation and interactive review covered every frame, all directions, three team colors, both rendering backends, live actions, portraits, editor previews, offscreen indicators and credits. Those assets are unchanged by the blur follow-up.

The rebased branch was rebuilt against master at 87bdee0fd. Validation includes:

  • Native release build, six render-pipeline tests, seven existing frame-index/sprite-centering tests, and git diff --check against the current base.
  • Strict translation audit, translation regression, font coverage and text wrapping checks. All 33 supported languages include the new motion-blur labels.
  • Settings/speed harness: default-off blur, old preferences, persistence, OK/Cancel/reopening, F8, language refresh, both speed selectors, multiplayer/replay restrictions, pause, and matching simulation/replay checksums.
  • Exhaustive shutter bounds/wrapping checks across all seven sets, eight directions, all 256 progress values, and spans through 480.
  • Software and OpenGL composite comparisons across all seven sets/eight directions and three team colors. Maximum GPU RGB difference from repeated draws: 3/255. Software difference: mean 5.38/255, max 10/255, including legacy background darkening.
  • Unbounded retention: 13,172 entries / 76,099,904 CPU pixel bytes, with an early entry still a cache hit after passing 64 MiB and no retained source recolor map.

Reproduction commands are in tools/unit-animation/MOTION-BLUR.md; Linux CI now runs the blur/cache checks on both backends. CI status below applies to the newly pushed revision.

HD validation after installation:

  • All 2,816 HD layers have the expected dimensions, paired-layer coverage and SHA-256 hashes. Production-only packaging reproduces all 2,887 runtime-pack files exactly.
  • All 2,828 native unit/icon files and Blender originals remain unchanged; the existing 60 HD world records are unchanged and their layers are byte-identical. Both original-art archive preservation checks pass.
  • All seven sets were reviewed across eight directions, loop boundaries and three team colors. Across all 1,792 poses, the largest alpha-centroid shift is 0.240 native pixels. The increased texture sampling changes antialiasing and reveals existing surface detail.
  • The installed HD cache test passes all frame mappings, seven actions/eight directions/three colors, cache reuse, whole-shutter fallback for incomplete packs, HD/classic switching and logical dimensions. At one texture pixel per physical screen pixel, cached versus repeated OpenGL compositing differs by at most 3/255 in RGB.
  • Game/editor/replay integration passes with HD and software fallback, including zoom, fullscreen, seams, input mapping, unchanged simulation checksums and texture release. The speed/settings/replay suite passes again with the installed pack. Actual world-unit captures confirm matching size with HD and optional blur.
  • The macOS app bundle includes the complete HD pack. Normal rendering remains 25 FPS.

Warmed-cache benchmark on the rebased macOS renderer, 300 fast workers:

Textures Sharp Repeated blur Cached blur Retained composite bytes across four cases
Native 1.58 ms 5.37 ms 1.17 ms 73.0 MiB
HD 1.57 ms 5.49 ms 1.18 ms 1.42 GiB

Every measured lookup hit. The four cases retain 3,456 composites; memory includes CPU pixels and GPU textures/mipmaps, with source textures additional. The cache remains unbounded. These sprite-only timings exclude first-use generation and are not whole-game frame times.

Review follow-up: GPU team-color shader, bounded CPU cache

Giszmo's motion-blur design is unchanged; this addresses the memory regression the
generation-budget follow-up didn't fix. The final-image/composite cache is gone.
Rebased onto master at the same base as the cold-cache follow-up.

World units, portraits, editor previews, indicators and credits now share one
implementation in DrawableSurface::drawSprite. A sharp draw and each pose of a
motion-blur shutter are ordinary drawSprite calls, one per pose -- the sequence
your review sketch describes directly, now that there's no intervening cache.

On GPU with a working shader, each pose is one textured quad: a GLSL 1.20 program
samples the base layer and the unrotated team layer, reproduces the CPU HSV hue
shift itself, composites team over base in premultiplied space (unpremultiplying
once, the same algebra the removed cache used), and applies the shutter's pose
weight to alpha only. No team-coloured or composite texture is ever created on
this path.
The shader is created with the GL context and destroyed before it's
torn down; a compile/link failure logs once and falls back to the CPU path for
that context's lifetime (also forceable with GLOB2_DISABLE_UNIT_SHADER=1, for
testing).

Without a working shader (software renderer, or that fallback), the unit sheet is
marked dynamicTeamColor and its CPU-recolored layers are now backed by one
sprite-wide, byte-accounted 64 MiB LRU, keyed by frame/resolution/color, in
place of the unbounded per-frame map. A motion-blur shutter still samples one
resolution throughout (Sprite::blockHasCompleteHD): if any frame in the current
32-phase block lacks its HD counterpart, the whole block falls back to native
rather than mixing resolutions pose to pose.

Measured results (this machine)

  • Shader correctness (UnitTeamShaderTest, all 1,792 poses, native+HD, three
    colors, plus the 12 team hues + 16-hue palette): HSV hue shift mean 0.059/255,
    max 1/255, zero alpha mismatches, over 10,074,752 channel comparisons. Sharp
    and 30-delta motion-blur framebuffer vs. an independent from-scratch CPU
    composite: max 2.27/255, over 4,194,304 comparisons.

  • Cache bound (UnitTeamColorCacheTest): zero team-colored surfaces created
    under the shader, sharp and blurred. With the shader forced off, 20,000+ distinct
    colors through the cache never exceeded 64 MiB; repeated draws hit, long-evicted
    colors regenerate correctly.

  • 12-team, 5,000-tick benchmark (new twelve-team-benchmark; a synthetic
    render-loop soak test through the real drawSprite/drawUnitMotionBlur path --
    156 units across 12 teams, not a full Cortex/generated-map run): on two
    generated-map seeds, blur off and on --

    Map Blur RSS growth (tick 2,500→5,000) Frame p95 Cache bytes (throughout)
    river off 0 B 0.83 ms 0
    river on -81,920 B 1.73 ms 0
    craters off 0 B 0.84 ms 0
    craters on -49,152 B 1.70 ms 0

    Versus the failing baseline this replaces: 4.0 GiB of composite storage and a
    5.79 GiB peak footprint after 5,000 ticks with 157 units.
    Checksums matched
    exactly between blur on/off at every 250-tick mark; p95 is far under the 40/50 ms
    targets.

  • Also passing: TestsRunner (180/180), WinningConditionsHarness,
    HighResolutionIntegrationHarness, EnteringUnitSaveHarness,
    ImmobileUnitGradientHarness, EnteringUnitDrawHarness,
    MapRenderGeometryTest, and the full settings/speed/replay/checksum suite
    (run-game-speed-tests.py).

  • MapRenderResizeHarness's expected > 0 failure is fixed by this change
    (checked directly against a clean pre-change build). Its later resize sections
    need a taller desktop than this dev machine has; software mode now gets the
    same graceful bail-out GPU mode already had there, instead of an assert.

  • Optimized client and server builds (scons, scons server=1) both build clean.

Full details and reproduction commands are in tools/unit-animation/MOTION-BLUR.md.
CI is running on this head (linux ubuntu 22.04/24.04, windows mingw-w64); I'll
follow up here once it reports. Holding this out of any merge automation until
then.

Native sprites, HD sprites and the original Blender sources are byte-for-byte
unchanged by this follow-up.

Review follow-up: self-review cleanup

Ran a quality pass over the redesign above (commit 36e55be) and fixed what it found:

  • Real gap: the GPU-without-a-working-shader fallback picked HD vs. native per
    frame index, bypassing blockHasCompleteHD -- a unit sprite could reach it with
    a motion-blur shutter mixing resolutions pose to pose on that path (the shader
    path itself was already correct). Now gated the same way there too.
  • Efficiency: blockHasCompleteHD rescanned up to 32 frame slots on every
    drawSprite call (once per pose during blur); it's now a one-bit-per-block
    cache recomputed only where the underlying HD arrays actually change.
  • Dropped a duplicate friend class GraphicContext; declaration, and reworded a
    few comments that referred to "the removed composite cache" to describe the
    current code instead.

Full suite re-run with unchanged results (UnitTeamShaderTest,
UnitTeamColorCacheTest, UnitHighResolutionCacheTest GPU+software,
HighResolutionIntegrationHarness, run-game-speed-tests.py, TestsRunner
180/180, the 12-team benchmark).

Part 1 follow-up: unify unit HD textures to 128x128 (memory optimization)

Follow-up to feedback that the HD memory footprint measured above (~1 GiB) wasn't
good enough. Root cause, found via direct GL instrumentation rather than guessing:
this engine never uses GL rectangle textures, so every individually-uploaded
texture is padded to the next power-of-two on upload. Unit HD textures rendered
at exactly 4x native size -- 128px for the 32px-native explorer set, but 152px
and 160px for the 38px/40px-native worker and warrior sets, both of which round
up to 256x256 on the GPU. 1,536 of 1,792 poses (86%) were in those wasteful
152/160 classes.

Fix: pin every unit HD texture to a fixed 128x128 canvas regardless of native
size (128 is already power-of-two, so this eliminates the padding waste
entirely), then re-rendered the six affected sets through the Blender 2.34
pipeline (explorer's own output is mathematically unchanged at 128 = 32*4 and
was confirmed byte-identical against a fresh render rather than assumed).

frames.txt's scale column is a strict integer parsed via operator>>/int()
in four places and can't hold a fractional ratio (128/38 ≈ 3.37) without
corrupting the row parse, so unit rows now carry a sentinel scale of 0; every
consumer compares against the new Sprite::highResolutionTextureSize constant
directly instead of native * scale. Non-unit categories are untouched and
keep their literal scale = 4.

Measured results (this machine)

Same methodology as above: DrawableSurface::allocatedTextureBytes() with every
pose's base and team layer uploaded once via the real shader draw path (not the
CPU fallback cache).

Configuration GPU texture bytes
Native only (no HD) 42,991,616 41.0 MiB
HD, old 128/152/160 mix 960,142,336 915.6 MiB
HD, new uniform 128 289,053,696 275.7 MiB

640 MiB (671,088,640 bytes, ~70%) reduction in unit HD GPU texture memory,
with no change to the native-only baseline.

Also verified:

  • UnitHighResolutionCacheTest (GPU + software), RuntimePackCheck (newly wired
    into src/SConscript -- exercising it for the first time surfaced and fixed an
    unrelated pre-existing bug: a benchmark loop referenced a terrain sprite that
    was never loaded, since terrain has no HD data and thus never appears in
    frames.txt), HighResolutionIntegrationHarness, test_render.py (8/8),
    validate_runtime.py, package_runtime.py --check, runtime_provenance.py --check, validate_recovered.py, TestsRunner (180/180),
    run-game-speed-tests.py, WinningConditionsHarness.
  • The re-render's downsample-vs-native error profile (mean 36.5/255, alpha-mean
    1.7/255 across all 2,816 frames) closely matches the already-shipped pack's own
    profile against the same native references (36.8/255, 2.0/255) -- confirming no
    quality regression from the new fixed-size Blender scene path, just the same
    established rendering fidelity at a smaller, power-of-two-friendly size.

GPU texture compression (S3TC/DXT5) as a further reduction on top of this is
scoped as a separate, independent follow-up and intentionally not part of this
change.

Part 2: S3TC/DXT5 texture compression

Landed sooner than originally sequenced -- the memory footprint after Part 1
alone wasn't a low enough bar for merge, so this followed immediately rather
than waiting for Part 1 to be validated in production first.

Rather than the offline stb_dxt-encoder-plus-DDS-sidecar design originally
scoped, DrawableSurface::uploadToTexture()'s existing HD mip-generation
loop now passes GL_COMPRESSED_RGBA_S3TC_DXT5_EXT as the internal format to
glTexImage2D directly, letting the driver compress during upload from the
same raw RGBA8 pixels it already builds for the mip chain. This produces the
exact same 4:1 ratio a precomputed DDS would (S3TC's compression ratio is
fixed by the format, not the encoder) with no vendored library, no new
build-time tool, and no new file format shipped alongside the PNGs --
confirmed on this machine's driver (Apple M3 / Metal-backed OpenGL 2.1) via
a standalone extension probe before writing any engine code. Detected via
GL_EXT_texture_compression_s3tc the same way isTextureSRectangle already
is; falls back to the exact previous uncompressed behavior when the
extension is absent, with GLOB2_DISABLE_S3TC forcing that fallback for
testing.

Measured results (this machine)

Isolating the HD-only delta from the constant baseline (fonts, cursor, UI
chrome) present in every measurement:

Configuration HD-only GPU texture bytes
Old 128/152/160 mix (original) 917,150,720 874.6 MiB
New uniform 128, uncompressed 246,062,080 234.7 MiB
New uniform 128, S3TC/DXT5 61,501,440 58.65 MiB

93.3% total reduction from the original baseline (874.6 MiB -> 58.65
MiB); the compression step alone is an exact 4.0x reduction on top of Part
1's 128x128 unification.

Also verified: UnitTeamShaderTest's shader-vs-independent-CPU-reference
tolerance is unaffected by DXT5 quantization (mean 0.0586/255, max 1/255,
identical to pre-compression) -- these are flat-shaded sprites, not
photographic content. UnitHighResolutionCacheTest (GPU + software),
RuntimePackCheck, HighResolutionIntegrationHarness,
UnitTeamColorCacheTest, UnitMotionBlurTest, TestsRunner (180/180), and
run-game-speed-tests.py all still pass.

CI fixes

Investigating why CI wasn't showing green surfaced two pre-existing issues,
unrelated to this change but blocking every platform from completing:

  • MapRenderResizeHarness's small-desktop bail-out returned exit code 1,
    which run-savegame-safety-tests.py's subprocess.run(check=True) treats
    as a hard failure -- the opposite of the graceful, non-assert bail-out it
    was meant to be. CI's own environment (dummy driver / Xvfb default) reports
    a 1024x768 usable area against the 1800x1100 this harness needs, hitting
    this on every platform before ever reaching later steps. Now returns 0:
    the environment being too small to run this specific check isn't a test
    failure.
  • .github/workflows/build.yml still invoked UnitCompositeCacheTest and
    UnitCompositeCacheGPUCheck, which stopped being built when the composite
    cache was replaced by the GPU team-color shader earlier in this PR;
    neither binary exists anymore. Repointed at UnitTeamShaderTest and
    UnitTeamColorCacheTest, and added RuntimePackCheck, which had no CI
    coverage at all despite exercising every category's shared frame parser.

Master comparison and a fix for the review's reported bugs

Answering "did we actually reduce memory vs. master, not just vs. this PR's
own earlier commits": ran the existing twelve-team-benchmark scenario (12
teams, 156 units, full-viewport redraw every tick) against literal master
(84a9b8b6, this branch's actual fork point) and against current HEAD.

Config GPU texture memory RSS
Master (8-pose, old unbounded per-color CPU cache) 85.25 MiB 247.4 MiB
Current, classic/no-HD 41.0 MiB 164.5 MiB
Current, HD on (default) 195.6 MiB 573.1 MiB

In classic mode this already uses less memory than master, despite 4x more
animation poses
-- master's architecture CPU-recolored each frame per team
color and uploaded a separate GPU texture per (frame, color) pair, so its
GPU cost scaled with team-color count; the shader recolors live from one
shared texture, so GPU cost is flat regardless of how many teams are in the
match. (Side finding: the "4.0 GiB / 5.79 GiB peak" figure quoted earlier in
this PR's own history wasn't measuring literal master -- it was a worse
intermediate state partway through this PR's development, before the shader
rewrite. Real master, under this same realistic load, plateaus around 85 MB,
not gigabytes.) With HD on, memory is higher than master, fairly, since
master never had this capability -- that gap is exactly what the two changes
above spent today shrinking.

Also fixed, from the smoke-test round: the square artifact under software
rendering.
Root cause: DrawableSurface::drawSurface's manual per-pixel
alpha blend computed x>>8 (divide by 256) instead of an accurate x/255
when combining a source/dest channel pair -- including at fully-transparent
source pixels, where the bias should be exactly zero. A single blend's
~0.4% error is invisible, but each motion-blur shutter step is one more
drawSurface call onto the same footprint, and a wide shutter at high game
speed stacks dozens of these, compounding into a visible darkening across
the sprite's whole bounding box. Verified directly: 60 stacked partial-alpha
draws over an opaque background drifted 200 -> 140 before the fix, and hold
exactly at 200 after. Replaced the divide with the standard
(x+1+(x>>8))>>8 identity (exact round(x/255) for x in [0, 65025]),
applied per-channel-pair with the same masking the surrounding code already
uses. New DrawableSurfaceBlendTest, wired into unit-blur-tests and CI,
checks both invariants directly (transparent draws never perturb the
destination; a half-opaque source converges to a stable value rather than
drifting) and is confirmed to fail against the pre-fix code.

Checked the other bug from that same round (illegible "GL only" zoom label
on high-res screens, MapZoomControls.h): that file predates this PR
entirely (3ad40cf3b, already on master via #218) -- matches your own
"probably independent bug of this branch" note, so left alone here rather
than folding an unrelated fix into this PR's diff.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TU4ZDnBKJKtfgGUNgsx194

@Giszmo Giszmo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TLDR: Going 8 → 32 poses quadruples the unit sprite set (704 → 2,816 files, 0.71 → 3.23 MB), but as plain hard-cut frames the extra poses are mostly wasted: at the default worker speed only 16 of the 32 are ever displayed, and even the fastest ground unit reaches about 20 of 32, so 32 plain poses look no better than 16 plain poses. What makes the animation smooth at every unit speed is motion blur in the renderer: average every pose the unit passed through since the last drawn frame, weighted by the time spent on each. That is a handful of extra alpha blits per visible unit, no new assets, and it turns the unused poses into visible smoothness; with it, the 32-pose set is clearly the best of the variants compared below, and its 2.5 MB is a small price. Ask: keep the 32 poses, add the full-frame motion blur described below, and keep the 3.5 MB of comparison GIFs/PNGs under tools/unit-animation/comparisons/ out of the repository (attach them to the PR instead). Details and comparisons below.

How often do the new poses actually get shown?

Walk speed (performance[WALK]) and startImage[WALK] come from the kDefaultUnitTypes table in src/game/entities/Race.cpp, loaded unconditionally via Race::loadDefault(); no map or race file overrides them. Running the real per-tick delta advance from Unit.cpp and this PR's unitAnimationFrame (src/render/UnitAnimation.h) against those speeds, at the default one render per tick, gives the number of distinct poses landed on per tile crossing:

worker level speed (delta/tick) 8 poses (master) 16 poses 32 poses (this PR)
0 (default, most units) 16 8 of 8 16 of 16 16 of 32
1 21 8 of 8 16 of 16 25 of 32
2 26 8 of 8 15 of 16 20 of 32
3 (fastest ground mover) 30 8 of 8 16 of 16 20 of 32

At level 0 the 32-pose scheme lands only on the even indices, so as hard-cut frames it is frame-for-frame identical to a 16-pose scheme; the odd half of the new assets is never drawn. At higher levels 32 poses does show a real but modest edge (20 to 25 distinct poses versus 16). The blend below is what turns the unused poses into visible smoothness.

Independent of speed: for actions that use the direction == 8 branch, (delta >> 5) * 32 can only ever select 8 of the 32 offsets in that block, so the 4x increase is structurally unreachable there.

Repository cost

git ls-tree -l blob sizes of data/gfx/unit*.png (not du, which rounds every small file up to a disk block):

files size
master (8 poses) 704 0.71 MB
this PR (32 poses) 2,816 3.23 MB
16-pose variant used in the comparison (even-numbered sprite IDs of this PR, equivalent to every second rendered pose for every action and direction) 1,408 ~1.6 MB

Separately, the PR commits about 3.5 MB of comparison GIFs and PNGs under tools/unit-animation/comparisons/. Those are review material, not game data, and would sit in every clone forever; please attach them to the PR instead.

Blend instead of shipping more frames

The renderer draws one pose per frame; at 25 frames per second a level-0 worker advances 2 poses per frame and a level-3 worker 3.75 poses (delta advances by the unit speed each tick, 8 delta units per pose). Whatever is skipped between two frames is what strobes. Two fixes were prototyped on this PR's sprites:

  1. Two-pose cross-fade: draw the current pose, then the next pose on top with alpha equal to delta's fractional position between them. Cheap, but it interpolates a single instant, so it can never blur more than the gap to one neighbour.
  2. Shutter motion blur: average every pose the unit passed through since the last drawn frame, each weighted by the time it was on screen (a box shutter over the whole frame interval). This is what a camera would record, and it scales itself: 2 to 3 poses at level 0, 4 to 5 at level 3.

Six-way comparison of plain and cross-faded 8, 16 and 32 poses, fastest ground mover (level-3 worker, speed 30/tick), built from the actual master and PR sprites through the same team-colour HSV shift as Sprite::getRotatedSurface, upscaled 8x. First at real speed, one frame per game tick:

six schemes at real speed

The same sequence at 25% speed, each tick held for four frames:

six schemes at 25% speed

Plain 32 versus plain 16 is hard to tell apart, matching the table above. Cross-fading helps and helps more with 32 poses than with 16, but the fast unit still strobes because 3.75 poses go by between frames.

Shutter blur on the 32-pose set, four columns: plain, two-pose cross-fade, half-frame shutter, full-frame shutter. Level-3 worker (3.75 poses per frame), real speed on top and 25% speed below:

shutter blur, level-3 worker

Level-0 worker (2 poses per frame):

shutter blur, level-0 worker

The full-frame shutter is the one to take: it removes the strobing on fast units and reads as a running figure, and on slow units it degrades gracefully to a light blur. The half-frame shutter is a film convention, not a physical one, and leaves part of the strobing in.

Implementation sketch

GraphicContext::drawSprite already takes an alpha parameter (libgag/include/SDLGraphicContext.h:255) and both render paths honour it: the GL path via the quad colour, the software path via the per-pixel blend loop in DrawableSurface::drawSurface. Magic effects, ghost buildings and particles already draw through it. Equal time weighting with ordinary over-compositing works by drawing the k-th pose with alpha = w_k / (w_0 + … + w_k), so the first pose is opaque and each later one is scaled by its share so far. Sketch, untested:

// Motion blur: composite every pose the unit passed through since the last drawn frame.
int base = imgid;                                    // action base, before unitAnimationFrame
int step = unitActionStepSpeed(unit->speed, unit->action, unit->dx, unit->dy);   // delta per tick, as Unit::step uses it
int span = std::max(1, step * globalContainer->settings.getGameSpeedRenderInterval());  // delta advanced since the last drawn frame
int from = delta - span + 1;                         // shutter covers delta positions [from, delta]
int drawn = 0;
for (int d = from & ~7; d <= delta; d += 8)          // one 8-delta bucket per pose
{
	int w = std::min(d + 7, delta) - std::max(d, from) + 1;   // time spent in this pose
	drawn += w;
	int id = unitAnimationFrame(base, dir, d & 255);
	int dx = (unitSprite->getW(id)-32)>>1, dy = (unitSprite->getH(id)-32)>>1;
	globalContainer->gfx->drawSprite(px-dx, py-dy, unitSprite, id, (Uint8)(255 * w / drawn));
}

Game speed presets interact with this and the sketch accounts for it: presets 0 to 4 shorten the tick (40 down to 16 ms) but still draw every tick, so the motion per drawn frame and therefore the blur span are unchanged; presets 5 to 10 draw only every 2nd, 2nd, 4th, 5th, 16th and 16th tick (Settings::getGameSpeedRenderInterval), so the unit moves several ticks between frames and the shutter must cover all of them. Keying the span on the delta advanced since the last drawn frame, rather than on the tick, gets both cases right. Standing units have span 1 and draw exactly one opaque pose, as today.

Cost

Per visible moving unit this is ceil(span / 8) + 1 sprite draws instead of one: 3 at level 0, 5 at level 3, more at the high speed presets. Each draw is two 38x38 layers (base plus team-colour layer). On the GL path that is a few extra textured quads per unit and not worth measuring. On the software path each blended pixel goes through the integer blend loop in DrawableSurface::drawSurface, roughly 20 instructions; 300 fast units on screen would add about 4 million blended pixels per frame, comparable to the terrain blit, so noticeable but not prohibitive on a software renderer. If that ever matters, the blended results repeat: the phase within a pose bucket (delta mod 8) only takes 1 value at speed 16 and 4 values at speeds 26 and 30, so there are 32 to 128 distinct blurred frames per action, direction and speed level, and they could be cached per team colour the same way RotatedImage::rotationMap already caches team-coloured frames. Not needed for a first version.

Edited 2026-09-09: consolidated this review and two follow-up comments into one post, replaced a GIF that exceeded GitHub's 5 MB image proxy limit, and extended the ask from a two-pose cross-fade to full-frame motion blur after prototyping both.

@genixpro
genixpro force-pushed the codex/unit-animation-32-frames branch from 5c4bcd5 to 89a245c Compare September 9, 2026 14:44
@genixpro genixpro changed the title Increase core unit animation poses from 8 to 32 Smooth unit animations with 32 poses and optional cached motion blur Sep 9, 2026
@genixpro

genixpro commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Implemented the full-frame shutter approach from your review in 89a245c, keeping the 32-pose assets. Final team-colored composites are generated on demand and retained in an unbounded per-sprite cache; source recolor maps are cleared so we do not retain a second color cache. Motion blur defaults on and can be disabled in General Settings or with F8. Existing speed controls also include 0.75x, 0.5x and 0.25x using longer ticks; normal rendering remains 25 FPS.

The seven before/after GIFs and two comparison sheets are now GitHub attachments in the description. I removed both illustration-only commits from the branch history, so those files will not be merged into the repository. The separate high-resolution experiment is excluded.

Local release build, settings/speed and simulation/replay checks, shutter indexing, and software/OpenGL cache checks passed. The unbounded retention test kept an early entry after exceeding 64 MiB. With 300 fast workers, warmed cached blur measured 0.80 ms on OpenGL / 0.27 ms in software, versus 3.46 / 1.42 ms for repeated blending. These are sprite-only timings and exclude first-use generation. The description documents the alpha-over approximation, transition-history limitation, pixel comparisons and reproduction commands; Linux CI now runs the cache checks too.

Merged current master (70a2c6f6c) to resolve the translation conflict, completed the new labels in all 33 supported languages, and reran the release build and focused checks successfully. Strict translation, font coverage and text wrapping checks also passed. Ready for another review; CI will run on the updated head.

@genixpro
genixpro requested a review from Giszmo September 9, 2026 14:45
@Giszmo

Giszmo commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

The game loop is 25fps but the rendering loop could very well be decoupled from that. I don't know how much effort that would be but it would be helpful in both directions. If the cpu is lagging due to background processes, drop rendering a few frames to keep the simulation at pace and prevent network games from getting stuck for all and when resources are plenty, let the 100Hz roll.

I recently dug deeper into motion blur and realized that my concept was too simple. You can't just add motion blur at 25Hz and the eye no more notices the low frame rate. For one, something moving fast like the arms of a maxed out worker advances several pixels between frames, so you need blur to avoid the retina from perceiving successive hand borders but an observer's eye can follow that hand in which case you don't want it to be blurry. Imagine a ball flying through the image. If you stare at the tree behind, you want blur but if you follow the ball you want no blur at all. The only way to both is with a continuous picture and the closest we can get to that is a high refresh rate.

I also learned that animation movies sometimes predict where the viewer is looking, so they might blur the tree behind the sharp ball in the previous example even though the trees are still in the frame.

Anyway, glob2 is dead. Long live glob2 🍷

@genixpro
genixpro force-pushed the codex/unit-animation-32-frames branch from b39f86c to 271a316 Compare September 9, 2026 18:39
@genixpro genixpro changed the title Smooth unit animations with 32 poses and optional cached motion blur Smooth unit animations with 32 poses, HD sprites and optional blur Sep 9, 2026
@genixpro

genixpro commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

@Giszmo I agree that decoupling rendering from the 25 Hz simulation is a worthwhile engine improvement. Higher refresh rates would address something motion blur alone cannot, and being able to skip rendering under load while keeping the simulation on pace also makes sense.

I want to take this step by step and avoid turning this into a single monster PR. I've tried the current build again and am happy with this as an incremental improvement. The plan is to merge this as it stands, then tackle independent rendering and the interpolation needed for smooth motion between simulation ticks in a separate follow-up, preserving simulation and multiplayer/replay behavior.

I'm also running low on GPT-6 Astra credits after the web browser and mobile ports, so I might not have enough left for that larger engine change until my account renews. I agree the bigger improvement is warranted; I just want to keep its scope and timing separate from this PR.

@genixpro

genixpro commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

@Giszmo would you be happy to approve this PR as it stands so we can merge this incremental improvement? The current changes include the optional cached motion blur and keep the simulation/render timing unchanged, and all CI checks are passing.

I agree with your recommendation: the plan for an upcoming separate PR is to decouple rendering from the simulation, including interpolation between simulation ticks, while preserving multiplayer and replay behavior. Let's keep that engine work separate so this PR stays manageable.

@Giszmo

Giszmo commented Sep 9, 2026

Copy link
Copy Markdown
Contributor
Screencast.From.2026-09-09.23-20-31.mp4

That motion blur though ...

@Giszmo

Giszmo commented Sep 9, 2026

Copy link
Copy Markdown
Contributor
Screencast.From.2026-09-09.23-31-30.mp4

I don't know. This is not how I imagined the globules to look like close up. That glossy skin deforming when they bend? @stephanemagnenat what do you say?

Also it's a bit weird how some things are high res now but others like the "inside" indicators scale terribly.
image

@Giszmo

Giszmo commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

It's a bit rough around the edges still and the resources needed are a pain. Loading a saved game it is too much to load all at once, wetting the cache. I think that has to be throttled if the game loop is not running at the target speed. For example, load 8 frames first and then, if we have air to breath, load more. Or more aggressively, load low res 8 frames first.

Screencast.From.2026-09-09.23-50-52.mp4

That speed indicator on top goes red here. It goes deep green later. Like down to 5% during later play but the initial burst is definitely reducing the game speed and I have quite the machine here.

@genixpro
genixpro force-pushed the codex/unit-animation-32-frames branch from 271a316 to 22534a7 Compare September 9, 2026 22:48
@genixpro

genixpro commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

@Giszmo thanks for testing this. I've pushed a follow-up (22534a7bd) addressing the cache warm-up burst and rebased onto current master, including the merged entering-unit draw-origin fix (#231).

World-unit drawing now has a shared soft 2 ms budget per displayed frame for generating new composites, including initial texture uploads. Existing cache hits draw immediately; other units temporarily use their current sharp native pose while requested composites become available over subsequent frames. The cache remains unbounded, with no additional retained fallback cache. Transparent pixels are also skipped during compositing. A single composite can exceed that budget, so this is a mitigation rather than a hard frame-time guarantee.

In a synthetic cold-cache test of 300 fast workers, the 95th-percentile sprite frame time fell from about 681 ms to 41 ms (worst frame 1,118 ms to 48 ms). This defers work: after 128 frames, the budgeted run had generated 128 composites rather than 3,072. It doesn't prove your particular save now runs smoothly, and the earlier warmed-cache benchmark missed this startup cost.

We also have #234, “Load artwork once and retain it across matches,” which addresses repeated HD loading and invalidation separately. Its larger-unit-pack tests reported one map's startup dropping from about 3.28 seconds to 49 ms. I also hit repeated HD decoding during this branch's settings/replay tests; those passed with an extended timeout. #234 isn't included in the numbers above, so we should retest the combined changes before calling loading solved.

On appearance, these renders preserve the original models, rigs and materials. Improving how the glossy skin and deformations look close up is worthwhile, but it's a separate artwork project. The mismatch with the existing indicators also deserves a UI/zoom consistency pass. I haven't redesigned either here. Motion blur remains optional in General Settings or via F8, which should help distinguish blur artifacts from the underlying sharp artwork.

The client build, cache checks on both backends, settings/replay assertions, game/editor integration and translation checks pass locally; fresh CI will validate the pushed revision. If the warm-up slowdown persists, could you share the save used in the video so we can test that exact case? Decoupling rendering from the simulation remains the planned separate engine PR.

@Giszmo

Giszmo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Concern up front: the composite cache introduced here has no upper bound, and its key space for a 12-team game with the HD pack is about 31 GB of system RAM plus 31–110 GB of texture memory. It will never actually reach that — the process dies first — and that is the problem: nothing in the code stops it growing. At native resolution the same ceiling is ~1.9 GB, which is reachable and survivable but still worth a bound. Details, method and suggested fixes below.

Everything else I checked looks good: simulation and replays are untouched (no src/unit, no Race.cpp, action bases keep their serialized values), the game-speed tables index correctly against GAME_SPEED_MINIMUM = -3, and CI is green on all three platforms.


Why the key space is large

Game::drawUnit routes every unit draw through Sprite::getCachedComposite, both the blurred branch (GameRenderUnits.cpp:102) and the plain one (GameRenderUnits.cpp:105), and entries are retained for the sprite's lifetime by design (Sprite.cpp:406, compositeCache.emplace at Sprite.cpp:535, cleared only by reloadHighResolution).

The cache key is the whole shutter — the vector of (frame, alpha) pairs — and the shutter start is from = delta - span + 1 (UnitAnimation.h), so the key depends on the exact delta, not on the pose. That matters because Unit::step never restarts delta: it carries the remainder across tile boundaries with delta += stepSpeed - UNIT_DELTA_QUANTUM (Unit.cpp:299), i.e. delta_{n+1} = (delta_n + step) mod 256. A unit therefore visits 256 / gcd(step, 256) distinct delta values, which is 256 for any odd step:

walk case step distinct deltas tile-crossing phases
level 0 straight 16 16 1 (exact loop)
level 0 diagonal 11 256 11
level 1 straight 21 256 21
level 1 diagonal 14 128 7
level 2 straight 26 128 13
level 3 straight 30 128 15
standing / harvest level 0 8 32 1 (exact loop)

That drift is not new — master's 8-pose indexing does the same thing — but it used to be invisible. Now each of those delta values is a separate cache entry, which is the gap between "1,792 frames exist" and 27,312 keys per team colour.

The numbers

Enumerated from the shipped tables: action bases from UnitSkin.cpp SKIN_OFFSETS, speeds from Race.cpp kDefaultUnitTypes (all four upgrade levels, so racetracks and swimming pools are included), the diagonal rule from UnitTiming.h, direction parity from UnitGeometry.cpp:157, and every delta the recurrence above reaches.

27,312 distinct keys per colour → 327,744 entries at 12 colours (any single game-speed setting).

12 colours entries RAM texture, rectangle texture, power-of-two + mips
motion blur off, native 21,504 0.13 GB 0.13 GB 0.32 GB
motion blur off, HD 21,504 2.0 GB 2.0 GB 5.0 GB
motion blur on, native 327,744 1.9 GB 1.9 GB 5.1 GB
motion blur on, HD 327,744 31 GB 31 GB 110 GB

RAM is the RGBA surface (38x38x4 native, 152x152x4 at HD); texture is the GL copy each DrawableSurface allocates on top of it, sized exactly on drivers with GL_TEXTURE_RECTANGLE and rounded 152 → 256 plus mips elsewhere. The HD rows apply when OpenGL is enabled, since loadExperimentFrame returns early without USEGPU (Sprite.cpp:364).

Turning motion blur off does not avoid the cache, it only shrinks the key set — the plain branch still caches one composite per (frame, colour), which is the first two rows: 2.0 GB at HD.

About the speed slider, since it is easy to overstate: presets 0.25x through 2.5x all use render interval 1 (Settings.cpp), so they produce the same keys and change nothing. Only 4x and above skip frames (interval 2, 4, 5, 16 — confirmed at EngineRun.cpp:48-49 and 503-509, where nextGuiStep gates gui.step()), and span = step * getGameSpeedRenderInterval() (GameRenderUnits.cpp:97) then widens the shutter. Each individual preset lands at 27–28k keys per colour; only changing speed mid-game accumulates them, up to 133,744 per colour if a player visits every preset. Live network games are unaffected — adjustableGameSpeed is false there.

How I verified it

The enumerator is a standalone program over UnitAnimation.h; to check it counts the same thing the engine does, I built the branch and drove Sprite::getCachedComposite over the worker-walk subset with the real delta recurrence. The enumerator predicts 5,184 keys per colour for worker walk; the live compositeCache held exactly 5,184 entries at 28.6 MiB native. Per-entry sizes and generation cost were measured the same way.

Per new entry at HD: about 440 KB and 4.45 ms to build (0.27 ms native), against a 40 ms frame budget at 25 fps. getCachedComposite does getHSV/setHSV in doubles over 23,104 pixels x 2 layers x up to 5 frames. So roughly every 2,400 new entries costs another gigabyte and about 11 seconds of accumulated stutter.

Suggested fixes, cheapest first

  1. Keep the per-frame recolour cache and composite at draw time. RotatedImage::rotationMap has a natural ceiling of frames x colours = 21,504 surfaces (0.13 GB native, 2.0 GB HD) and this PR deletes it in favour of a key that is 15x larger. Drawing the shutter as N alpha blits is what master does and what the review sketch proposed; the composite only pays off when the same exact shutter recurs, and the delta carry means it often does not.
  2. Bound the cache — a byte-budgeted LRU. A 64 MiB cap existed and was removed; the test now asserts unbounded retention.
  3. Quantise the shutter start to delta & ~7. The drawn pose is delta >> 3, so it is bit-identical; only the sub-pose weighting rounds. That cuts 27,312 keys per colour to 7,352. Helpful, not sufficient on its own.
  4. At minimum, do not route the non-blur branch through the cache (GameRenderUnits.cpp:105), so users who turn motion blur off get master's memory behaviour back.

Smaller things noticed while reading

  • getColoredSurface in compositeOnly mode clones the frame and hue-shifts it on every call with no caching (Sprite.cpp:210). The unit info panel and the map editor's unit picture still draw via drawSprite, so at HD that is a 152x152 clone plus a full HSV pass every frame one is on screen.
  • Loading the HD unit set takes 1.25 s against 0.10 s native, partly because loadExperimentFrame rescans the whole pack manifest per frame (Sprite.cpp:370) — about 5M stream reads for 1,792 frames.
  • direction == 8 still maps to (delta >> 5) * 32, the first pose of each of the eight direction blocks, and motion blur is disabled for it (GameRenderUnits.cpp:94). Units at a resource are exactly this case (MapPathfindRessource.cpp:53), so the 4x pose count does not reach them.
  • One merge conflict with master, in src/render/GameRenderUnits.cpp.

@Giszmo

Giszmo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor
image At 40x speed, units jump several tiles per frame so the blur over their full rotation twice ... makes no sense. Also pausing the game shows me how the renderer fills in the missing assets over 3s until much later in the game when it's mostly saturated. Also I suspect the blur to be off in that it takes one image at full opacity and then adds the others at the correct opacity. Still+blur. The blur should be the result of sampling over a longer shutter time with all images contributing equally.

The final-image/composite cache (getCachedComposite, drawCachedComposite,
beginCompositeFrame, the 2ms warm-up budget and its sharp-native fallback)
is gone. Sharp draws and every pose of a motion-blur shutter are now
ordinary GraphicContext::drawSprite calls, one per pose, matching Giszmo's
review sketch directly.

On GPU, DrawableSurface::drawSprite routes a dynamicTeamColor sprite (the
unit sheet, so world units, portraits, editor previews, indicators and
credits all share this) through a new GLSL 1.20 program
(GraphicContextUnitShader.cpp): it samples the base and unrotated team
layer, reproduces the CPU HSV hue shift in the fragment shader, composites
team over base in premultiplied space, and applies the shutter weight to
alpha only. No team-coloured or composite texture is ever created on this
path. The shader is created with the GL context in setRes and destroyed
before it is torn down; a compile/link failure (or GLOB2_DISABLE_UNIT_SHADER,
for testing) logs once and falls back to the CPU path for that context's
lifetime.

The CPU path replaces the unbounded per-frame rotationMap, for this sprite
only, with a sprite-wide 64 MiB byte-accounted LRU
(Sprite::teamColorList/teamColorIndex) keyed by frame, resolution and team
color. Sprite::blockHasCompleteHD keeps a motion-blur shutter at one
resolution throughout, falling back to native for the whole 32-phase block
if any frame in it lacks an HD counterpart, rather than mixing resolutions
pose to pose.

Validation (all real runs, not estimates):
- UnitTeamShaderTest: shader vs CPU HSV over all 1,792 poses (native/HD,
  three colors) and the 12 team hues + 16-hue palette -- mean 0.059/255,
  max 1/255, zero alpha mismatches. Sharp/motion-blur framebuffer vs an
  independent CPU reference -- max 2.27/255.
- UnitTeamColorCacheTest: zero team-colored surfaces under the shader
  (sharp and blurred); the bounded fallback never exceeded 64 MiB across
  20,000+ colors, with LRU hit/evict/regenerate behavior confirmed.
- UnitHighResolutionCacheTest: all 1,792 layer mappings, HD/native
  switching, zoom, and a synthetic corrupted-HD-install case.
- MapRenderResizeHarness's "expected > 0" failure is fixed by this change
  (confirmed against a clean baseline checkout); its later resize sections
  need a taller desktop than this machine has, so software mode now gets
  the same graceful bail-out GPU mode already had instead of an assert.
- TestsRunner (180/180), WinningConditionsHarness, MapRenderGeometryTest,
  HighResolutionIntegrationHarness, EnteringUnitSaveHarness,
  ImmobileUnitGradientHarness, EnteringUnitDrawHarness, and the full
  settings/speed/replay/checksum suite (run-game-speed-tests.py) all pass.
- New synthetic 12-team, 5,000-tick benchmark (twelve-team-benchmark,
  documented in MOTION-BLUR.md): team_color_cache_bytes and GPU-allocated
  bytes stayed exactly flat, RSS growth from tick 2,500 to 5,000 was zero
  or negative on all four (map x blur) runs -- versus the prior baseline's
  4.0 GiB of composite storage and 5.79 GiB peak footprint at 5,000 ticks
  -- checksums matched exactly between blur on/off, and frame p95 (0.8ms
  sharp, 1.7ms blurred) is far under the 40/50ms targets. It's a synthetic
  render-loop soak test reusing the real draw path, not a full Cortex/map-
  generation run.

Native sprites, HD sprites and Blender sources are untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TU4ZDnBKJKtfgGUNgsx194
@genixpro

Copy link
Copy Markdown
Contributor Author

@Giszmo Following up on the composite-cache memory concern flagged in review: it's gone. Pushed a redesign (028993f) that replaces the final-image cache with GPU shader-based team coloring, plus a bounded CPU fallback. Summary in the updated PR description above; the short version:

  • Each pose (sharp draw, or one step of a motion-blur shutter) is now a single ordinary drawSprite call — the exact sequence your sketch describes, since there's no cache sitting in between anymore.
  • On GPU, a GLSL 1.20 shader does the HSV recolor and base/team compositing per-fragment from the unrotated team layer, so no team-coloured or composite texture is ever created. Verified against an independent CPU reference over all 1,792 poses (native+HD) and 28 team hues: max HSV error 1/255, sharp/blur framebuffer max error 2.27/255.
  • Without a working shader (software renderer, or a forced fallback for testing), recoloring is backed by one 64 MiB byte-accounted LRU shared by world units and every UI preview, in place of the unbounded per-frame map. Verified it never exceeds the bound across 20,000+ distinct colors.
  • New 12-team, 5,000-tick synthetic soak benchmark (twelve-team-benchmark, real render path, not a full AI/map-gen run): cache bytes and GPU-allocated bytes flat throughout, RSS growth zero-to-negative from tick 2,500→5,000 on both a river-seed and crater-lakes-seed run, blur on and off, versus the prior baseline's 4.0 GiB / 5.79 GiB peak. Checksums match exactly between blur variants.

CI is running on the pushed head now (linux 22.04/24.04, windows mingw-w64) — I'll follow up once it reports. Would appreciate another look whenever you have a chance.

- DrawableSurfaceCompound.cpp: the GPU-without-a-working-shader fallback
  (routed through the sized drawSprite overload) was choosing HD vs. native
  per frame index, ignoring blockHasCompleteHD. A unit sprite could reach it
  with an HD pack that's complete for one pose in a motion-blur shutter but
  not another, mixing resolutions pose to pose -- the exact thing
  blockHasCompleteHD exists to prevent on the shader path. Now gated the
  same way there too.
- Sprite::blockHasCompleteHD rescanned up to 32 frame slots on every
  drawSprite call (so once per pose during blur). It's now backed by a
  one-bit-per-block cache recomputed in load()/reloadHighResolution(), the
  only two places the underlying HD arrays change in production code.
- Dropped a duplicate `friend class GraphicContext;` in Sprite (one already
  existed).
- Reworded a handful of comments that referred to "the removed composite
  cache" -- describe the current code, not what it replaced.
- UnitHighResolutionCacheTest's synthetic corrupted-HD-pack helpers now
  call recomputeBlockCompleteHD after poking the arrays directly, since
  they bypass the normal load/reload invalidation path.

Re-ran the full suite: UnitMotionBlurTest, UnitTeamShaderTest,
UnitTeamColorCacheTest and UnitHighResolutionCacheTest (GPU and software),
HighResolutionIntegrationHarness, run-game-speed-tests.py, TestsRunner
(180/180), and the 12-team benchmark all still pass with unchanged results.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TU4ZDnBKJKtfgGUNgsx194
@Giszmo

Giszmo commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Amazing work! I tried it out and it has no initial lag, no rolling rendering, cpu is bored.

Please let the PR rest a bit this time to get @kylelutze and @stephanemagnenat also to try it out!

Bugs found on a quick smoke test:

With software rendering, units carry squares. This is more visible on speed x40.

image

Also in game:

image

probably independent bug of this branch: the scale doesn't scale with software renderer. Either remove it or make it work.

image

Edit: Yeah, "GL only" ... I can't read it on my high res screen.

genixpro and others added 6 commits September 10, 2026 17:06
Every unit HD pose currently renders at exactly 4x its native size: 128px
for the 32px-native explorer set, 152px for the 38px-native worker sets,
160px for the 40px-native warrior sets. This engine never uses GL rectangle
textures, so every individually uploaded texture gets padded to the next
power-of-two on upload -- 128 is already po2 (no waste), but 152 and 160
both round up to 256x256, wasting most of the allocation. 1,536 of 1,792
poses (86%) fall in the wasteful 152/160 classes.

Pin every unit HD texture to a fixed 128x128 canvas instead, regardless of
native size (4x for explorer, ~3.37x/3.2x for the worker/warrior sets).
frames.txt's scale column is a strict integer and can't hold a fractional
ratio without corrupting the row parse, so unit rows now carry a sentinel
scale of 0; every consumer compares against Sprite::highResolutionTextureSize
directly instead of native*scale. Non-unit categories are unaffected and
keep writing/reading a literal scale of 4.

DrawableSurfaceCompound's sized drawSprite overloads dropped a
scale-correction computation for the team layer that only ever evaluated to
1.0 under the old uniform-4x invariant (dead code); the base and team layers
now both draw into the same destination box unconditionally, which is also
correct for the new non-uniform ratios.

render.py gains a --highres-pixel-size flag (used instead of
--resolution-scale for unit sets) and a fixed_size parameter on prepare()
that patches the Blender scene's render dimensions directly rather than
scaling them from native size. Validation tooling (validate_runtime.py,
runtime_provenance.py) and docs are updated for the new per-set ratios.

RuntimePackCheck.cpp, exercised for the first time by wiring it into
src/SConscript, surfaced a pre-existing bug unrelated to this change: its
dense-scene benchmark referenced a "terrain" sprite that was never loaded
(frames.txt only lists categories with HD data, and terrain has none), so
std::map::operator[] silently inserted a null Sprite*. Fixed by loading it
explicitly alongside the other reference sprites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TU4ZDnBKJKtfgGUNgsx194
Re-rendered all six non-explorer sets (worker-walk, worker-swim,
worker-harvest, warrior-walk, warrior-swim, warrior-fight; 2,304 frames)
through the Blender 2.34 pipeline with --highres-pixel-size 128. Explorer's
own HD output is unaffected (128 = 32 * 4 either way, whether reached via
--resolution-scale or --highres-pixel-size) and reuses the existing frames,
confirmed byte-identical against a fresh render of all 256 of its poses.

Measured directly via DrawableSurface::allocatedTextureBytes() with every
pose and its team layer uploaded once (matching the real gameplay/shader
draw path, not the CPU-fallback cache): total unit HD GPU texture memory
drops from 960,142,336 bytes (915.6 MiB, the old 128/152/160 mix) to
289,053,696 bytes (275.7 MiB) -- a 671,088,640 byte (640 MiB, ~70%)
reduction, with the native-only (no HD) baseline unchanged at 42,991,616
bytes (41.0 MiB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TU4ZDnBKJKtfgGUNgsx194
Unit HD textures (and any other highResolutionSampling surface reached
outside the atlas path) now upload their mip chain as
GL_COMPRESSED_RGBA_S3TC_DXT5_EXT instead of GL_RGBA when
GL_EXT_texture_compression_s3tc is present, detected the same way
isTextureSRectangle already is. This needs no offline encoder or vendored
library: passing raw RGBA8 pixels to glTexImage2D with a compressed
internal format has the driver encode during upload, at the same fixed 4:1
ratio (16 bytes per 4x4 block) a precomputed DDS would give. Block
compression is undefined below 4x4, so the mip chain stops there instead of
continuing to the 2x2/1x1 tail, with GL_TEXTURE_MAX_LEVEL set accordingly.
Falls back to the exact previous uncompressed behavior, unconditionally,
when the extension isn't present; GLOB2_DISABLE_S3TC forces that fallback
for testing.

Measured via DrawableSurface::allocatedTextureBytes(), isolating the HD-only
delta from the constant baseline (fonts, cursor, UI chrome) present in every
measurement: HD unit GPU texture memory drops from 234.7 MiB (post-128x128
unification, still uncompressed) to 58.65 MiB -- a further exact 4.0x
reduction. Combined with the 128x128 unification, that's 874.6 MiB down to
58.65 MiB from the original 128/152/160 mix, a 93.3% total reduction.
UnitTeamShaderTest's shader-vs-independent-CPU-reference pixel tolerance is
unaffected (mean 0.0586/255, max 1/255, unchanged) -- these are flat-shaded
sprites, not photographic content, and DXT5 quantization on them is within
existing rounding noise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TU4ZDnBKJKtfgGUNgsx194
.github/workflows/build.yml still invoked UnitCompositeCacheTest and
UnitCompositeCacheGPUCheck, which stopped being built when the composite
cache was replaced by the GPU team-color shader and bounded CPU cache;
neither binary exists anymore, so this step could only ever fail once
reached. Point it at the tests that actually cover that code today
(UnitTeamShaderTest, UnitTeamColorCacheTest) and add the newly-wired
RuntimePackCheck, which exercises every category's shared frame parser and
had no CI coverage at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TU4ZDnBKJKtfgGUNgsx194
Both desktop-size guards printed their "use a sufficiently large desktop or
Xvfb" message and returned 1, which run-savegame-safety-tests.py's
subprocess.run(check=True) treats as a hard failure -- the opposite of the
graceful, non-assert bail-out this was meant to be, and precisely the
condition CI's own dummy/1024x768 environment hits on every platform before
ever reaching the later resize checks the properly-sized xvfb invocation
exists to cover. Return 0 instead: the environment being too small to run
this specific check is not a test failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TU4ZDnBKJKtfgGUNgsx194
Giszmo's smoke test found units carrying a visible dark square in software
rendering, worse at high game speed. The manual per-pixel blend in
DrawableSurface::drawSurface computed x>>8 (divide by 256) instead of an
accurate x/255 wherever it combined a source/dest channel pair, including
when the source pixel's own alpha is 0. A single blend's ~0.4% bias is
invisible, but each motion-blur shutter step is one more `drawSurface` call
onto the same footprint, and at a wide shutter (high game speed) dozens of
these compound multiplicatively -- darkening the sprite's whole bounding
box, transparent edges included, into the reported square.

Replaced the >>8 with the standard (x+1+(x>>8))>>8 identity, which equals
round(x/255) exactly for x in [0, 65025]; applied per-lane with the same
0x00FF00FF masking the existing code already uses to keep the two packed
channels from carrying into each other. Verified directly: 60 stacked
partial-alpha draws over an opaque background drifted from 200 to 140
before this fix and hold exactly at 200 after. New DrawableSurfaceBlendTest
(wired into unit-blur-tests and CI) checks both that fully-transparent
pixels never perturb the destination under many stacked draws, and that a
half-opaque source converges to a stable value rather than drifting.

Checked the other bug from the same review round (illegible "GL only" zoom
label on high-res screens, MapZoomControls.h): that file predates this PR
entirely (`3ad40cf3b`, already on master via #218), matching Giszmo's own
"probably independent bug of this branch" note -- left alone here rather
than folding an unrelated fix into this PR's diff.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TU4ZDnBKJKtfgGUNgsx194
@genixpro

Copy link
Copy Markdown
Contributor Author

@Giszmo Following up with the memory work and the bugs from your last smoke test.

Memory. The HD pack's actual root cause: this engine never uses GL rectangle
textures, so every individually-uploaded texture pads to the next power-of-two.
Units rendered at exactly 4x native -- 128px for the 32px-native explorer set, but
152px/160px for the 38px/40px-native worker and warrior sets, both padding up to
256x256 and wasting most of the allocation (86% of poses were in that wasteful
class). Fixed by pinning every unit HD texture to a fixed 128x128 canvas
regardless of native size, then re-rendering the six affected sets through the
Blender pipeline (explorer's own output is unchanged at 128 = 32*4 and confirmed
byte-identical against a fresh render). Layered S3TC/DXT5 compression on top --
glTexImage2D with a compressed internal format lets the driver compress
directly from the same RGBA8 pixels the mip-chain builder already produces, no
offline encoder or vendored library needed, same fixed 4:1 ratio a precomputed
DDS would give. Falls back to the previous uncompressed behavior when
GL_EXT_texture_compression_s3tc isn't present.

Measured via DrawableSurface::allocatedTextureBytes(), isolating the HD-only
delta from the constant baseline (fonts, cursor, UI chrome) present in every
measurement:

Configuration HD-only GPU texture bytes
Old 128/152/160 mix 917,150,720 (874.6 MiB)
Uniform 128, uncompressed 246,062,080 (234.7 MiB)
Uniform 128, S3TC/DXT5 61,501,440 (58.65 MiB)

93.3% reduction from the original baseline. UnitTeamShaderTest's
shader-vs-independent-CPU-reference tolerance is unaffected (mean 0.0586/255,
max 1/255, identical to pre-compression) -- these are flat-shaded sprites, not
photographic content.

I also went back and compared against literal master (84a9b8b6, this branch's
fork point), not just this PR's own earlier commits, using the existing
twelve-team-benchmark scenario (12 teams, 156 units, full-viewport redraw every
tick):

Config GPU texture memory RSS
Master (8-pose, old unbounded per-color CPU cache) 85.25 MiB 247.4 MiB
This PR, classic/no-HD 41.0 MiB 164.5 MiB
This PR, HD on (default) 195.6 MiB 573.1 MiB

In classic mode we're already using less memory than master, despite 4x more
animation poses -- master's architecture CPU-recolored per team color and
uploaded a separate GPU texture per (frame, color) pair, so GPU cost scaled with
team-color count; the shader recolors live from one shared texture, so GPU cost
is flat regardless of team count. (Side note: the "4.0 GiB / 5.79 GiB" figure
quoted earlier in this thread wasn't literal master -- it was a worse
intermediate state partway through this PR's own history, before the shader
rewrite. Real master, under the same load, plateaus around 85 MB.) With HD on,
memory is higher than master, fairly, since master never had this capability --
that gap is exactly what the two changes above spent today shrinking.

Bugs from your smoke test:

  • Units carrying squares in software rendering. Root cause:
    DrawableSurface::drawSurface's manual per-pixel alpha blend computed x>>8
    (divide by 256) instead of an accurate x/255, including at fully-transparent
    source pixels where the bias should be exactly zero. A single blend's ~0.4%
    error is invisible, but each motion-blur shutter step is one more
    drawSurface call onto the same footprint, and a wide shutter at high game
    speed stacks dozens of these, compounding into the visible darkening you saw.
    Verified directly: 60 stacked partial-alpha draws over an opaque background
    drifted 200 -> 140 before the fix, hold exactly at 200 after. Replaced the
    divide with the standard (x+1+(x>>8))>>8 identity (exact round(x/255)).
    New DrawableSurfaceBlendTest (wired into CI) checks this directly and is
    confirmed to fail against the pre-fix code.
  • Illegible "GL only" zoom label. Checked -- MapZoomControls.h predates this
    PR entirely (3ad40cf3b, already on master via Add high-resolution rendering support and shared map zoom #218), matching your own
    "probably independent bug of this branch" guess. Left it alone here rather
    than folding an unrelated fix into this PR's diff; happy to pick it up
    separately if useful.

Everything above is pushed and CI-covered. Also fixed two things CI itself
needed: MapRenderResizeHarness's small-desktop bail-out was returning exit
code 1 (treated as a hard failure) instead of 0 for an environment too small to
run that specific check, and the workflow still referenced
UnitCompositeCacheTest/UnitCompositeCacheGPUCheck, removed when the
composite cache became the shader -- both were masking whether the actual
unit/HD tests were passing at all.

Given the above, I think this is ready -- would appreciate an approval whenever
you get a chance. Fully respect the ask to also get @kylelutze and
@stephanemagnenat's eyes on it; not trying to route around that, just wanted the
efficiency concern and the smoke-test bugs closed out first.

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