Smooth unit animations with 32 poses, HD sprites and optional blur - #201
Smooth unit animations with 32 poses, HD sprites and optional blur#201genixpro wants to merge 13 commits into
Conversation
There was a problem hiding this comment.
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:
- 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.
- 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:
The same sequence at 25% speed, each tick held for four frames:
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:
Level-0 worker (2 poses per frame):
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.
5c4bcd5 to
89a245c
Compare
|
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 ( |
|
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 🍷 |
b39f86c to
271a316
Compare
|
@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. |
|
@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. |
Screencast.From.2026-09-09.23-20-31.mp4That motion blur though ... |
Screencast.From.2026-09-09.23-31-30.mp4I 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. |
|
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.mp4That 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. |
271a316 to
22534a7
Compare
|
@Giszmo thanks for testing this. I've pushed a follow-up ( 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. |
|
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 Why the key space is large
The cache key is the whole shutter — the vector of
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 numbersEnumerated from the shipped tables: action bases from 27,312 distinct keys per colour → 327,744 entries at 12 colours (any single game-speed setting).
RAM is the RGBA surface (38x38x4 native, 152x152x4 at HD); texture is the GL copy each 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 ( How I verified itThe enumerator is a standalone program over 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. Suggested fixes, cheapest first
Smaller things noticed while reading
|
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
|
@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:
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
|
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.
Also in game:
probably independent bug of this branch: the scale doesn't scale with software renderer. Either remove it or make it work. Edit: Yeah, "GL only" ... I can't read it on my high res screen. |
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
|
@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 Measured via
93.3% reduction from the original baseline. I also went back and compared against literal master (
In classic mode we're already using less memory than master, despite 4x more Bugs from your smoke test:
Everything above is pushed and CI-covered. Also fixed two things CI itself Given the above, I think this is ready -- would appreciate an approval whenever |








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
masterat84a9b8b60, 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
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.data/gfx, and the untouched Blender sources indatasrc/gfx/originals/units.datasrc/gfx/production/original-derived; package them intodata/highres/v1with the established artwork pipeline. Preserve the existing 60 world frames.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.
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
Explorer flight
Worker swimming
Worker harvesting / building
Warrior walking
Warrior swimming
Warrior fighting
At equal points in the cycle, the old animation holds each pose while the new animation supplies 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.
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:
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
masterat87bdee0fd. Validation includes:git diff --checkagainst the current base.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:
Warmed-cache benchmark on the rebased macOS renderer, 300 fast workers:
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
masterat 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 amotion-blur shutter are ordinary
drawSpritecalls, one per pose -- the sequenceyour 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, fortesting).
Without a working shader (software renderer, or that fallback), the unit sheet is
marked
dynamicTeamColorand its CPU-recolored layers are now backed by onesprite-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 current32-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, threecolors, 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 createdunder 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 syntheticrender-loop soak test through the real
drawSprite/drawUnitMotionBlurpath --156 units across 12 teams, not a full Cortex/generated-map run): on two
generated-map seeds, blur off and on --
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'sexpected > 0failure 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:
frame index, bypassing
blockHasCompleteHD-- a unit sprite could reach it witha 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.
blockHasCompleteHDrescanned up to 32 frame slots on everydrawSpritecall (once per pose during blur); it's now a one-bit-per-blockcache recomputed only where the underlying HD arrays actually change.
friend class GraphicContext;declaration, and reworded afew comments that referred to "the removed composite cache" to describe the
current code instead.
Full suite re-run with unchanged results (
UnitTeamShaderTest,UnitTeamColorCacheTest,UnitHighResolutionCacheTestGPU+software,HighResolutionIntegrationHarness,run-game-speed-tests.py,TestsRunner180/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'sscalecolumn is a strict integer parsed viaoperator>>/int()in four places and can't hold a fractional ratio (
128/38 ≈ 3.37) withoutcorrupting the row parse, so unit rows now carry a sentinel
scaleof 0; everyconsumer compares against the new
Sprite::highResolutionTextureSizeconstantdirectly instead of
native * scale. Non-unit categories are untouched andkeep their literal
scale = 4.Measured results (this machine)
Same methodology as above:
DrawableSurface::allocatedTextureBytes()with everypose's base and team layer uploaded once via the real shader draw path (not the
CPU fallback cache).
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 wiredinto
src/SConscript-- exercising it for the first time surfaced and fixed anunrelated pre-existing bug: a benchmark loop referenced a
terrainsprite thatwas 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.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-generationloop now passes
GL_COMPRESSED_RGBA_S3TC_DXT5_EXTas the internal format toglTexImage2Ddirectly, letting the driver compress during upload from thesame 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_s3tcthe same wayisTextureSRectanglealreadyis; falls back to the exact previous uncompressed behavior when the
extension is absent, with
GLOB2_DISABLE_S3TCforcing that fallback fortesting.
Measured results (this machine)
Isolating the HD-only delta from the constant baseline (fonts, cursor, UI
chrome) present in every measurement:
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-referencetolerance 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), andrun-game-speed-tests.pyall 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'ssubprocess.run(check=True)treatsas 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.ymlstill invokedUnitCompositeCacheTestandUnitCompositeCacheGPUCheck, which stopped being built when the compositecache was replaced by the GPU team-color shader earlier in this PR;
neither binary exists anymore. Repointed at
UnitTeamShaderTestandUnitTeamColorCacheTest, and addedRuntimePackCheck, which had no CIcoverage 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-benchmarkscenario (12teams, 156 units, full-viewport redraw every tick) against literal master
(
84a9b8b6, this branch's actual fork point) and against current HEAD.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-pixelalpha blend computed
x>>8(divide by 256) instead of an accuratex/255when 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
drawSurfacecall onto the same footprint, and a wide shutter at high gamespeed 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))>>8identity (exactround(x/255)forxin[0, 65025]),applied per-channel-pair with the same masking the surrounding code already
uses. New
DrawableSurfaceBlendTest, wired intounit-blur-testsand 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 PRentirely (
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