App 2.40.2: the Grid map moves on the GPU

The map was hardware-rendered, but every frame recomputed every road
point, rebuilt every path and drew the big roads three times, at 20
frames a second, and the car jumped with each GPS fix. Now it is its own
view under the Grid (MapLayerView, a hardware layer): drawn once into a
GPU texture and only redrawn when tiles change or after about 230 m;
each display frame only sets its translation and rotation. The car
glides between fixes, and the Grid over the map redraws only with the
readings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-27 00:32:50 +01:00
co-authored by Claude Opus 5.5
parent c3c47f5159
commit f7c489858e
7 changed files with 293 additions and 129 deletions
+38 -125
View File
@@ -27,7 +27,9 @@ import android.os.SystemClock;
* marker low on the screen; between GPS fixes the car's position runs on
* with the speed. The map is then the whole background, straight from above (about
* 400 m ahead, 450 m to each side), over a ground grid (a line every 50 m) that turns and slides
* with the roads; the speed, the battery wall, the disc and the clock stand on dark panels over
* with the roads, fading into the dark towards the top. It is its own view under this one
* ({@link MapLayerView}): drawn once into a hardware layer and moved and turned by the GPU each
* display frame, the car gliding between fixes; the speed, the battery wall, the disc and the clock stand on dark panels over
* it, and the trail makes way for the map. Without a fix, a server or the roads, the plain floor with a line
* saying why.
*
@@ -41,11 +43,6 @@ final class GridView extends ClusterCanvasView {
/** Battery segments in the light wall, disc segments, metres per grid tile. */
private static final int WALL = 20, DISC = 60;
private static final float TILE_M = 4f;
/**
* The map, straight from above: pixels (in layout units) per metre, the car's height above
* the bottom edge, roads from FADE_M ahead fainter, a ground-grid line every GRID_M.
*/
private static final float MAP_SCALE = 1.2f, CAR_FROM_BOTTOM = 150f, FADE_M = 250f, GRID_M = 50f;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -60,12 +57,10 @@ final class GridView extends ClusterCanvasView {
private final int accent, accentLight;
/** Null for the plain Grid. */
private final MapRoads map;
/** Per road class, near and far (from {@link #FADE_M} ahead). */
private final Path[] roads = new Path[RoadTile.CLASSES * 2];
private float heading = Float.NaN;
private final Path gridPath = new Path();
/** This frame's map: pixels per metre and where the car is on screen. */
private float mapPx, carSx, carSy;
/** The map under this view (a hardware layer, moved by the GPU); null for the plain Grid. */
private final MapLayerView mapLayer;
private final Paint fade = new Paint();
private int fadeFor = -1;
private int main, light, dim, alert;
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
@@ -80,7 +75,7 @@ final class GridView extends ClusterCanvasView {
GridView(Context context, MapRoads map) {
super(context);
this.map = map;
for (int i = 0; i < roads.length; i++) roads[i] = new Path();
mapLayer = map == null ? null : new MapLayerView(context, map);
accent = AccentColor.resolve(context, R.attr.evAccent);
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
line.setStyle(Paint.Style.STROKE);
@@ -96,9 +91,24 @@ final class GridView extends ClusterCanvasView {
dim = AccentColor.withAlpha(main, 0x40);
alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F;
skyFor = -1;
if (mapLayer != null) mapLayer.setColours(main, light);
invalidate();
}
/** The map layer, for the pane to put under this view; null for the plain Grid. */
MapLayerView mapLayer() {
return mapLayer;
}
@Override
void setValues(ClusterValues values) {
super.setValues(values);
if (mapLayer != null) {
mapLayer.setSpeed(v.speedKmh);
mapLayer.kick();
}
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
@@ -106,15 +116,14 @@ final class GridView extends ClusterCanvasView {
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
advance();
c.drawColor(0xFF000000);
// With a map, the map is the whole background, seen from straight above, and the rest
// stands on dark panels over it.
boolean mapShown = map != null && map.fix() != null && !map.tiles().isEmpty();
// With a map, the map layer underneath is the whole background (this view stays clear
// there), and the rest stands on dark panels over it.
boolean mapShown = mapLayer != null && mapLayer.isShowing();
float horizon = h * 0.55f;
if (mapShown) {
drawMap(c, w, h, u);
drawCar(c, w, h, u);
} else {
c.drawColor(0xFF000000);
drawSky(c, w, horizon);
drawFloor(c, w, h, horizon, u);
}
@@ -131,8 +140,8 @@ final class GridView extends ClusterCanvasView {
if (mapShown) drawAttribution(c, w, h, u);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
// The map costs more per frame: 20 a second is smooth enough for it.
keepAnimating(moving, mapShown ? 50 : 33);
// Over the map only the readings change; the map layer moves itself.
keepAnimating(moving && !mapShown, 33);
}
/** Moves the floor by the distance driven since the last frame, and turns the disc. */
@@ -180,97 +189,15 @@ final class GridView extends ClusterCanvasView {
// ---------- Map ----------
/** The roads round the car from straight above, heading up, over a ground grid. */
private void drawMap(Canvas c, float w, float h, float u) {
MapRoads.Fix fix = map.fix();
java.util.Collection<RoadTile> tiles = map.tiles();
if (fix == null) return;
// Heading up: ease round the shorter way; north up until the car has moved.
float target = Float.isNaN(fix.bearing) ? (Float.isNaN(heading) ? 0f : heading) : fix.bearing;
if (Float.isNaN(heading)) heading = target;
else heading = (heading + (((target - heading) % 360f + 540f) % 360f - 180f) * 0.2f + 360f) % 360f;
// Between fixes the car runs on with the speed, for at most 2.5 s.
double mpu = MapGeometry.metresPerUnit(fix.lat);
float ran = 0f;
if (v.speedKmh != null && !Float.isNaN(fix.bearing)) {
ran = v.speedKmh / 3.6f * Math.min(2.5f, (SystemClock.elapsedRealtime() - fix.elapsedMs) / 1000f);
/** Over the map: the far roads fading into the dark towards the top, and the car. */
private void drawCar(Canvas c, float w, float h, float u) {
float carSx = w / 2f, carSy = h - MapLayerView.CAR_FROM_BOTTOM * u;
if (fadeFor != (int) (carSy + h)) {
fade.setShader(new LinearGradient(0, 0, 0, carSy, 0xB0000000, 0x00000000, Shader.TileMode.CLAMP));
fadeFor = (int) (carSy + h);
}
double run = Math.toRadians(Float.isNaN(fix.bearing) ? 0f : fix.bearing);
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu;
double carY = MapGeometry.worldY(fix.lat) - ran * Math.cos(run) / mpu;
mapPx = MAP_SCALE * u;
carSx = w / 2f;
carSy = h - CAR_FROM_BOTTOM * u;
float sin = (float) Math.sin(Math.toRadians(heading)), cos = (float) Math.cos(Math.toRadians(heading));
float m = (float) mpu;
// Everything on screen is within this many metres of the car.
float reach = (float) Math.hypot(w / 2f, carSy) / mapPx + GRID_M;
double range = reach / mpu;
// The ground grid, fixed to the ground: it turns and slides with the roads.
gridPath.reset();
double carEast = carX * mpu, carNorth = -carY * mpu;
float offE = (float) (carEast % GRID_M), offN = (float) (carNorth % GRID_M);
int lines = (int) (reach / GRID_M) + 1;
for (int k = -lines; k <= lines; k++) {
float e = k * GRID_M - offE, n = k * GRID_M - offN;
gridLine(e, -reach, e, reach, sin, cos);
gridLine(-reach, n, reach, n, sin, cos);
}
for (Path p : roads) p.reset();
for (RoadTile tile : tiles) {
double ox = (double) tile.x * MapGeometry.EXTENT - carX, oy = (double) tile.y * MapGeometry.EXTENT - carY;
if (ox > range || oy > range || ox + MapGeometry.EXTENT < -range || oy + MapGeometry.EXTENT < -range) {
continue;
}
float fx = (float) ox, fy = (float) oy;
for (int i = 0; i < tile.points.length; i++) {
int[] b = tile.bounds[i];
if (fx + b[2] < -range || fx + b[0] > range || fy + b[3] < -range || fy + b[1] > range) continue;
int cls = tile.classes[i];
int[] pts = tile.points[i];
float pa = 0f, pf = 0f;
Path prev = null;
for (int k = 0; k < pts.length; k += 2) {
float east = (fx + pts[k]) * m, north = -(fy + pts[k + 1]) * m;
float fwd = east * sin + north * cos, side = east * cos - north * sin;
if (k > 0) {
Path p = roads[cls * 2 + (Math.min(pf, fwd) > FADE_M ? 1 : 0)];
if (p != prev) p.moveTo(screenX(pa), screenY(pf));
p.lineTo(screenX(side), screenY(fwd));
prev = p;
}
pa = side;
pf = fwd;
}
}
}
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
line.setColor(AccentColor.withAlpha(main, 0x26));
line.setStrokeWidth(1f * u);
c.drawPath(gridPath, line);
// Far roads thinner and fainter.
for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) {
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x60 : 0x90));
line.setStrokeWidth((cls == 0 ? 2.4f : cls == 3 ? 1.2f : 1.8f) * u);
c.drawPath(roads[cls * 2 + 1], line);
}
line.setColor(AccentColor.withAlpha(main, 0xA0));
line.setStrokeWidth(1.8f * u);
c.drawPath(roads[3 * 2], line);
line.setColor(AccentColor.withAlpha(main, 0xD8));
line.setStrokeWidth(2.4f * u);
c.drawPath(roads[2 * 2], line);
glow(c, roads[2], main, 2.8f * u);
glow(c, roads[0], light, 3.6f * u);
line.setStrokeJoin(Paint.Join.MITER);
line.setStrokeCap(Paint.Cap.SQUARE);
// The car: a light-cycle chevron pointing ahead.
c.drawRect(0, 0, w, carSy, fade);
// A light-cycle chevron pointing ahead.
path.reset();
path.moveTo(carSx, carSy - 18f * u);
path.lineTo(carSx + 12f * u, carSy + 11f * u);
@@ -282,20 +209,6 @@ final class GridView extends ClusterCanvasView {
glow(c, path, light, 1.5f * u);
}
private float screenX(float side) {
return carSx + side * mapPx;
}
private float screenY(float ahead) {
return carSy - ahead * mapPx;
}
/** A grid line between two ground points given east and north of the car, in metres. */
private void gridLine(float e0, float n0, float e1, float n1, float sin, float cos) {
gridPath.moveTo(screenX(e0 * cos - n0 * sin), screenY(e0 * sin + n0 * cos));
gridPath.lineTo(screenX(e1 * cos - n1 * sin), screenY(e1 * sin + n1 * cos));
}
/** Dark panels under the battery wall and the clock, so the map does not run through them. */
private void drawPanels(Canvas c, float w, float horizon, float u) {
chamfer(16f * u, 78f * u, 360f * u, horizon + 86f * u, 14f * u);