Skip to content

Android: Implement proper DPI-aware scaling and fullscreen mode#3

Open
automaton82 wants to merge 17 commits into
plushmonkey:masterfrom
Automaton-Systems:android-improvements
Open

Android: Implement proper DPI-aware scaling and fullscreen mode#3
automaton82 wants to merge 17 commits into
plushmonkey:masterfrom
Automaton-Systems:android-improvements

Conversation

@automaton82
Copy link
Copy Markdown
Contributor

@automaton82 automaton82 commented May 23, 2026

DPI-Aware Rendering:

  • Implement dynamic DPI scaling using AConfiguration_getDensity() API
  • Calculate logical screen dimensions for game rendering (physical pixels / DPI scale)
  • Game now renders to logical dimensions while OpenGL scales to physical resolution
  • All UI elements (radar, chat, HUD, text) now scale proportionally across devices
  • Separate scaling for ImGui menus (1.33x) vs in-game rendering (2.4x on 560 DPI)
  • Font sizes now scale with device DPI for readability (1.5x multiplier on menus)

Immersive Fullscreen Mode:

  • Add hideSystemUI() method to MainActivity for edge-to-edge rendering
  • Hide status bar and navigation bar using WindowInsetsController (API 30+)
  • Fall back to SYSTEM_UI_FLAG_IMMERSIVE_STICKY for older Android versions
  • Restore immersive mode when window regains focus

Touch Controls:

  • Virtual d-pad (140px diameter) in bottom-left with green directional arrows
  • Angle-based ship steering: d-pad direction maps directly to ship heading
  • GUN and BOMB round buttons (72px) in bottom-right with grey border / blue fill
  • Multi-touch support: fly and shoot simultaneously with separate pointer tracking
  • Ability buttons (Burst, Repel, Decoy, Thor, Brick, Rocket, Portal) fire once per tap

Mobile UI Layout:

  • Menu redesigned as two-column button layout (420×240px); tap top of screen to open
  • Ship selection via right-column buttons (Warbird through Spectator)
  • Radar moved to top-right corner
  • FPS counter moved to top-left (y=40), notifications positioned directly below
  • Item icons bottom-aligned on left side; status icons bottom-aligned on right side
  • Timed indicators (Portal, Super, Shield, Flag) rendered on left with text to the right of icon
  • Energy bar shortened to 160px and centered at top; energy value shown as text below bar

Bug Fixes:

  • Fix blurry/missing textures on high-DPI displays (Samsung Galaxy S25 Ultra) by changing tile renderer shader precision from mediump to highpmediump float (16-bit) was insufficient for UV coordinate calculations at world coordinates >512
  • Going 'home' during game play and returning resulted in a black screen. Cause: APP_CMD_TERM_WINDOW/INIT_WINDOW are not guaranteed symmetric on modern Android (Samsung One UI / Android 14+). The old architecture destroyed the entire EGL stack on TERM_WINDOW and relied
    on INIT_WINDOW to recreate it, which never fired on resume from the app switcher.

Build System Updates:

  • Update CMake version from 3.10.2 to 3.22.1 to match installed SDK
  • Update local server IP addresses (192.168.7.168)

Documentation:

  • Add comprehensive CLAUDE.md with full development workflow
  • Document build process, emulator setup, programmatic control via adb
  • Include coordinate transformation formulas and automation examples
  • Update README.md with Android build instructions

render: fix CPU-GPU sync stalls causing 20 FPS on mobile
SpriteRenderer was called 4+ times per frame, each time uploading
sprite data with glBufferSubData. On mobile unified-memory GPUs
(Mali, Adreno), glBufferSubData stalls the CPU until the GPU
finishes reading the previous buffer. Measured 33-50ms per call
even with zero sprites — pure sync overhead.

Fix in three parts:

  1. SpriteRenderer::Render() now uploads all sprites at once with
    glBufferData(GL_STREAM_DRAW). Buffer orphaning causes the driver
    to allocate fresh storage, so the CPU never waits on in-flight
    GPU reads. Draw calls then use vertex offsets into the already-
    uploaded VBO — no re-upload per texture batch.

  2. WeaponManager::Render() no longer calls renderer.Render()
    internally. Weapon sprites are pushed to the buffer and flushed
    by the game loop, same as every other subsystem.

  3. Game::RenderGame() reduced from 4+ sprite flush points to exactly
    two: one world-space flush (weapons, players, tiles) and one
    UI-space flush (HUD, radar, chat, ship controller).

Result: 20 FPS -> 93 FPS on Samsung SM-X400 (Mali-G68).

Testing: Verified on Samsung Galaxy S25 Ultra (1440×3120 @ 560 DPI) and Android emulator (2400×1080 @ 420 DPI)

Example screenshot:

Screenshot_1779582761

This commit addresses critical UX issues on high-DPI Android devices:

DPI-Aware Rendering:
- Implement dynamic DPI scaling using AConfiguration_getDensity() API
- Calculate logical screen dimensions for game rendering (physical pixels / DPI scale)
- Game now renders to logical dimensions while OpenGL scales to physical resolution
- All UI elements (radar, chat, HUD, text) now scale proportionally across devices
- Separate scaling for ImGui menus (1.33x) vs in-game rendering (2.4x on 560 DPI)
- Font sizes now scale with device DPI for readability (1.5x multiplier on menus)

Immersive Fullscreen Mode:
- Add hideSystemUI() method to MainActivity for edge-to-edge rendering
- Hide status bar and navigation bar using WindowInsetsController (API 30+)
- Fall back to SYSTEM_UI_FLAG_IMMERSIVE_STICKY for older Android versions
- Restore immersive mode when window regains focus

Build System Updates:
- Update CMake version from 3.10.2 to 3.22.1 to match installed SDK
- Update local server IP addresses (192.168.7.168)

Documentation:
- Add comprehensive CLAUDE.md with full development workflow
- Document build process, emulator setup, programmatic control via adb
- Include coordinate transformation formulas and automation examples
- Update README.md with Android build instructions

Testing: Verified on Samsung Galaxy S23 Ultra (1440x3120 @ 560 DPI)
and Android emulator (2400x1080 @ 420 DPI)
Repositioned right-side icons (spectator indicator, ship items, weapon/bomb
indicators) from y=57% to y=50 pixels to prevent overlap with radar minimap
on DPI-scaled screens.

Changes:
- Move SpectateView icon from calculated position to fixed y=50
- Move ShipController indicators from calculated position to fixed y=50
- Position icons just below FPS counter with proper clearance
- Make gradlew executable for convenience

The dynamic DPI scaling introduced in previous commit changed logical screen
dimensions, causing the percentage-based positioning (57% of screen height) to
overlap with the bottom-right radar. Fixed positioning at y=50 ensures icons
are visible below FPS while keeping clear space for the radar minimap.

Tested on emulator (2400x1080 @ 420 DPI) and Samsung S23 Ultra (1440x3120 @ 560 DPI).
@automaton82 automaton82 marked this pull request as draft May 23, 2026 23:49
@automaton82
Copy link
Copy Markdown
Contributor Author

There are some issues I found I will fix first.

- Multi-touch support: simultaneous d-pad flying and weapon buttons
- Virtual d-pad (140px) in bottom-left with green directional arrows
- GUN/BOMB round buttons (72px) in bottom-right with grey/blue styling
- Ship controls: virtual d-pad angle calculation for directional flying
- Ability buttons fire once per tap (trigger flag prevents repeat)
- Menu redesigned: two-column button layout (420x240px), tap top UI to open
- Icons bottom-aligned: item icons left, status icons right
- Timed indicators moved to left side with text offset right of icon
- FPS counter moved to top-left, notifications positioned below it
- Radar moved to top-right corner
- Energy bar shortened to 160px, energy value shown as text
- Fix shader precision mediump->highp in TileRenderer to fix blurry
  textures on high-DPI displays (Samsung Galaxy S25 Ultra)
The root cause: APP_CMD_TERM_WINDOW/INIT_WINDOW are not guaranteed
symmetric on modern Android (Samsung One UI / Android 14+). The old
architecture destroyed the entire EGL stack on TERM_WINDOW and relied
on INIT_WINDOW to recreate it, which never fired on resume from the
app switcher.

Fix:
- Keep EGLDisplay + EGLContext + GL resources alive across pause/resume
- Only destroy EGLSurface on APP_CMD_TERM_WINDOW (destroySurfaceOnly)
- Add ensureSurface() which lazily recreates the surface whenever the
  render loop finds g_EglSurface == EGL_NO_SURFACE and app->window != nullptr
- ensureSurface() also called in APP_CMD_INIT_WINDOW and APP_CMD_GAINED_FOCUS
  as opportunistic hints (not relied upon exclusively)
- Check eglSwapBuffers result; on EGL_BAD_SURFACE/EGL_CONTEXT_LOST call
  destroySurfaceOnly() so the next tick recreates it
- Store g_EglConfig globally so surface can be recreated without reinit
… recreate

When ensureSurface() recreates the EGL surface after resume, it must also:
- Call ANativeWindow_setBuffersGeometry with the original scaled dimensions
  (width * 0.6, height * 0.6) so the surface comes back at the same size
- Call glViewport with the stored physical dimensions to restore the GL
  viewport

Store egl_format, buffer dimensions, and physical dimensions globally so
they are available to ensureSurface() without a full reinit.
SpriteRenderer was called 4+ times per frame, each time uploading
sprite data with glBufferSubData. On mobile unified-memory GPUs
(Mali, Adreno), glBufferSubData stalls the CPU until the GPU
finishes reading the previous buffer. Measured 33-50ms per call
even with zero sprites — pure sync overhead.

Fix in three parts:

1. SpriteRenderer::Render() now uploads all sprites at once with
   glBufferData(GL_STREAM_DRAW). Buffer orphaning causes the driver
   to allocate fresh storage, so the CPU never waits on in-flight
   GPU reads. Draw calls then use vertex offsets into the already-
   uploaded VBO — no re-upload per texture batch.

2. WeaponManager::Render() no longer calls renderer.Render()
   internally. Weapon sprites are pushed to the buffer and flushed
   by the game loop, same as every other subsystem.

3. Game::RenderGame() reduced from 4+ sprite flush points to exactly
   two: one world-space flush (weapons, players, tiles) and one
   UI-space flush (HUD, radar, chat, ship controller).

Result: 20 FPS -> 93 FPS on Samsung SM-X400 (Mali-G68).
…te file

Platform-specific UI layout changes are now guarded so the shared
src/null/ code compiles correctly on Windows/Linux without mobile
assumptions baked in:

- Notification.cpp: position guarded (mobile: top-left, desktop: proportional)
- Radar.cpp: radar position guarded (mobile: top-right, desktop: bottom-right)
- SpectateView.cpp: icon y-position guarded
- ShipController.cpp:
  - Energy digit display restored on desktop, guarded off on Android
  - RenderTimedIndicator position/alignment guarded (left vs right side)
  - Item indicator y-positions guarded (bottom-anchored vs proportional)
  - kBarWidth constant (160 Android / 240 desktop)
  - Energy text label guarded to Android-only
  - GUN/BOMB buttons and D-pad wrapped in #ifdef __ANDROID__
- Game.cpp: FPS counter position guarded (top-left Android / top-right desktop)
- Game.h + src/null/android/GameMenuAndroid.cpp: Android menu moved to
  its own file via RenderMenuAndroid(). Desktop RenderMenu() in Game.cpp
  restored to original. Menu layout will diverge freely without polluting
  shared code.
@automaton82 automaton82 marked this pull request as ready for review May 24, 2026 02:49
@automaton82
Copy link
Copy Markdown
Contributor Author

automaton82 commented May 24, 2026

@plushmonkey this is a large change as I had to change quite a lot to make the experience better on Android. There was also a few rendering issues. Let me know if you have any questions. I used Claude to assist as my C++ is pretty awful.

I did manually test on the emulator and my phone as noted in the PR, plus a Samsung Tab S10 Lite (which had severe rendering issues, it was stuck at 20fps until b4f1496 was done).

I did not test on Windows or Linux.

The __ANDROID__ ifdefs will probably continue to grow as I modify the UI. If you dislike this approach, I can just keep it in my own branch but I these changes are good for Android.

Major Features:
- Redesigned ESC menu: 3-button sidebar (Quit, Stat Box, Help) + 3x3 ship grid with rendered sprites
- Portal/Warp toggle: Tap portal icon when active to warp back
- Multifire toggle: Tap gun icon to toggle multifire mode (with sound)
- Mine mode toggle: Tap bomb icon to switch between bombs and mines
  * Button text changes from 'BOMB' (white) to 'MINE' (yellow)
  * Sound feedback on mode switch
- Toggleable abilities: Tap right-side icons to toggle stealth, cloak, xradar, antiwarp
- Afterburner: Drag beyond d-pad radius to activate, with 'AB' indicator and green ring
- Random player names: 'null space XXX' with 3-digit random suffix to prevent collisions

UI Improvements:
- Enlarged menu buttons (35px height) for better touch targeting
- Ship selection grid with visual ship sprites (110x70 cells)
- Mine mode visual feedback (yellow text in button)
- Afterburner visual indicator (text + ring around d-pad)
- Stat Box accessible from menu without closing menu

Quality of Life:
- Spectator mode now spawns at map center (512, 512) instead of origin
- All Android-specific UI properly isolated with #ifdef __ANDROID__
- Clean separation of Android menu code in GameMenuAndroid.cpp

Technical:
- Added g_AndroidMineMode global for cross-file mine state tracking
- Portal/warp toggle checks ship_controller.ship.portal_time
- Mine mode integrates with existing Mine InputAction
- All new features use action_callback for proper toggle behavior
Maps with an embedded BMP tileset (e.g. apocalypse.lvl) replace the
default tile graphics. Custom tilesets typically leave the tile 171
(safe zone) slot empty, so the shader sampled empty/black pixels and
the safe zone area appeared invisible in the main view (the radar
worked because it uses a hardcoded green color for id 171).

Tiles >172 are protected by a black-pixel discard fallback in the
shader, and tiles 170/172 are drawn by other systems, so tile 171 was
uniquely affected.

After uploading the map's tileset to the 3D texture array, override
layer 170 with the safe zone graphic from graphics/tiles.bm2 so the
safe zone always renders correctly regardless of the map's tileset.
Wormholes (220) now render as dark purple, asteroids (216-218) as brown/
orange, and space stations (219) as medium gray on the minimap. Previously
these tiles rendered as generic dark gray, making them indistinguishable
from walls.
Join and leave messages now use the notification system on Android (5-second
timeout) instead of the persistent chat log. This prevents them from staying
on screen indefinitely over the d-pad.

Desktop retains the original chat log behavior for these messages.
UI / rendering:
- Energy number: render beside the centered bar on Android (between bar
  and radar) instead of top-right; remove the separate small Blue text
  underneath the bar (now uses the same big-sprite digits as desktop).
- FPS counter: hide on Android (it was overlapping the statbox area).
- Timed indicators (portal / super / shield / flag): right-anchor on
  both platforms; on Android stack them above the right-side icon column
  so they no longer collide with the energy bar.
- Notifications: anchor below the statbox on Android instead of using
  a fixed y=55; pass the statbox bottom from Game::RenderGame.

Statbox (Android):
- Dynamically size sliding_view.size to roughly match the radar height
  (derived from the same surface_dim.x / 6 formula Radar uses), so the
  list fits the visible area on any device.
- Default to StatViewType::Points (more useful than the Names view on a
  small screen).
- Show a '+N more' overflow indicator in Names, Points, TeamSort, and
  Full views when not all players fit.
- Widen name column (12 -> 18 chars, kNamesWidth 108 -> 140) so longer
  player names aren't truncated.

Score / flag sync:
- Register and parse ProtocolS2C::ScoreUpdate (0x09): updates
  kill_points / flag_points / wins / losses on the target player so
  scoreboard numbers stay in sync with the server.
- On PlayerDeath, provisionally credit kill bounty to the killer
  (skipping self-kills and team-kills) so points update immediately;
  the next ScoreUpdate from the server corrects any drift.
- Radar: show dropped flags for both own team (RadarTeamFlag) and
  enemy team (RadarEnemyFlag); skip flags with id 0xFFFF or not in
  the Dropped state.
- Handle ProtocolS2C::FlagVictory: push a green VICTORY notification
  with the points awarded (or a yellow info line for other freqs).

Android server config:
- Update 'local' and 'subgame' zone IPs from 192.168.7.168 to
  192.168.7.161 (host LAN address changed).
Do not skip the flag proximity loop when CarryFlags=0. Turf flag
games (fg_turf) run with CarryFlags=0 but still require the client
to send the 0x13 touch packet for server-side ownership tracking.
The inner can_carry / GameFlag_Turf check already handles both modes.

Also fix carry_count <= 0 guard (was == 0, missed negative values).
- Show notification when any player picks up or drops the flag
- Color-coded: green for teammates, red for enemies
- Improves situational awareness in flag games
Implement automatic expiration of chat messages on Android to prevent
screen clutter. Messages now disappear after 15 seconds instead of
persisting indefinitely.
Exclude consumables and Multiprize from InitialBounty spawn prize loop in ResetShip():
- Repel, Burst, Decoy, Thor, Rocket, Portal (consumables)
- Multiprize (can grant consumables via sub-loop)

Without this fix, InitialRepel/InitialBurst/etc. config settings were meaningless
because the InitialBounty prize loop would randomly grant these items regardless,
overriding the configured Initial values. This appears to be an SVS bug where
consumables were incorrectly included in spawn rewards.

Now Initial* config values work as intended: ships spawn with configured amounts
(typically 0), and players collect consumables from green prize boxes during gameplay.
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.

1 participant