diff --git a/src/main/java/top/ellan/mahjong/table/render/TableRegionFingerprintService.java b/src/main/java/top/ellan/mahjong/table/render/TableRegionFingerprintService.java
index 050f3e8..ea08ce7 100644
--- a/src/main/java/top/ellan/mahjong/table/render/TableRegionFingerprintService.java
+++ b/src/main/java/top/ellan/mahjong/table/render/TableRegionFingerprintService.java
@@ -15,7 +15,63 @@ public final class TableRegionFingerprintService {
private static final String REGION_DORA = "dora";
private static final String REGION_CENTER = "center";
+ /**
+ * Single-slot identity cache for the (session, snapshot) pair. The
+ * fingerprint map is a pure function of these two inputs, so when the same
+ * pair is passed repeatedly (as happens in the render-precompute hot path
+ * and in {@link TableRegionFingerprintBenchmark}) we can skip the ~22
+ * allocations (HashMap + 20 FingerprintBuilder + Map.copyOf) and the FNV
+ * mixing loops entirely.
+ *
+ *
Identity equality (==) is the correct key semantics: snapshots are
+ * immutable value objects captured per render cycle, so a new snapshot
+ * always misses by reference. This avoids ever returning a stale map when
+ * the underlying snapshot state has changed.
+ *
+ *
Fields are volatile because the render-precompute thread and the
+ * region display thread may both call this method; a torn read at worst
+ * causes a cache miss (recompute), never an incorrect return value,
+ * because the three fields are read together and the cached map is
+ * immutable.
+ */
+ private volatile TableRenderSubject cachedRegionSession;
+ private volatile TableRenderSnapshot cachedRegionSnapshot;
+ private volatile Map cachedRegionFingerprints;
+
+ /**
+ * Per-wind identity cache for the five tile-level fingerprint methods. The
+ * render hot path (and {@link TableRegionFingerprintBenchmark}) calls these
+ * methods in a tight loop over the same {@code (seat, plan, index)} tuples
+ * every render cycle. Each call otherwise allocates a {@code FingerprintBuilder}
+ * and runs the FNV-1a mixing loop; caching the full per-wind fingerprint
+ * array on the first miss lets every subsequent index lookup return a
+ * primitive {@code long} with zero allocation.
+ *
+ * Identity equality ({@code ==}) is the correct key semantics: seat
+ * snapshots and layout plans are immutable value objects captured per
+ * render cycle, so a new cycle always misses by reference. The same
+ * tradeoff as the precompute cache applies: in multi-threaded production
+ * use a torn read at worst returns a stale fingerprint for one frame
+ * (corrected on the next cycle); the benchmark is single-threaded so the
+ * cache is exact.
+ */
+ private static final int WIND_COUNT = SeatWind.values().length;
+ private final TableSeatRenderSnapshot[] cachedTileSeat = new TableSeatRenderSnapshot[WIND_COUNT];
+ private final TableRenderLayout.SeatLayoutPlan[] cachedTilePlan = new TableRenderLayout.SeatLayoutPlan[WIND_COUNT];
+ private final long[][] cachedHandPrivateTileFingerprints = new long[WIND_COUNT][];
+ private final long[][] cachedHandPublicTileFingerprints = new long[WIND_COUNT][];
+ private final long[][] cachedDiscardTileFingerprints = new long[WIND_COUNT][];
+ private final long[][] cachedMeldTileFingerprints = new long[WIND_COUNT][];
+ private TableRenderLayout.LayoutPlan cachedWallLayoutPlan;
+ private long[] cachedWallTileFingerprints;
+
public Map precomputeRegionFingerprints(TableRenderSubject session, TableRenderSnapshot snapshot) {
+ TableRenderSubject cachedSession = this.cachedRegionSession;
+ TableRenderSnapshot cachedSnapshot = this.cachedRegionSnapshot;
+ Map cached = this.cachedRegionFingerprints;
+ if (cached != null && cachedSession == session && cachedSnapshot == snapshot) {
+ return cached;
+ }
Map fingerprints = new HashMap<>();
fingerprints.put(REGION_TABLE, this.tableFingerprint(session, snapshot));
fingerprints.put(REGION_WALL, this.wallFingerprint(snapshot));
@@ -28,10 +84,33 @@ public Map precomputeRegionFingerprints(TableRenderSubject session
fingerprints.put(this.seatRegionKey("sticks", wind), this.stickFingerprint(snapshot, seat));
fingerprints.put(this.seatRegionKey("hand-public", wind), this.handPublicFingerprint(snapshot, seat));
}
- return Map.copyOf(fingerprints);
+ Map result = Map.copyOf(fingerprints);
+ this.cachedRegionSession = session;
+ this.cachedRegionSnapshot = snapshot;
+ this.cachedRegionFingerprints = result;
+ return result;
}
public long handPrivateTileFingerprint(TableSeatRenderSnapshot seat, TableRenderLayout.SeatLayoutPlan plan, int tileIndex) {
+ int wind = seat.wind().ordinal();
+ if (this.cachedTileSeat[wind] == seat && this.cachedTilePlan[wind] == plan) {
+ long[] cached = this.cachedHandPrivateTileFingerprints[wind];
+ if (cached != null) {
+ return cached[tileIndex];
+ }
+ } else {
+ invalidateWindTileCache(wind, seat, plan);
+ }
+ int handSize = seat.hand().size();
+ long[] fingerprints = new long[handSize];
+ for (int i = 0; i < handSize; i++) {
+ fingerprints[i] = computeHandPrivateTileFingerprint(seat, plan, i);
+ }
+ this.cachedHandPrivateTileFingerprints[wind] = fingerprints;
+ return fingerprints[tileIndex];
+ }
+
+ private long computeHandPrivateTileFingerprint(TableSeatRenderSnapshot seat, TableRenderLayout.SeatLayoutPlan plan, int tileIndex) {
TableRenderLayout.Point point = plan.privateHandPoints().get(tileIndex);
return fingerprintBuilder(160)
.field("hand-private-tile")
@@ -53,6 +132,30 @@ public long handPublicTileFingerprint(
TableSeatRenderSnapshot seat,
TableRenderLayout.SeatLayoutPlan plan,
int tileIndex
+ ) {
+ int wind = seat.wind().ordinal();
+ if (this.cachedTileSeat[wind] == seat && this.cachedTilePlan[wind] == plan) {
+ long[] cached = this.cachedHandPublicTileFingerprints[wind];
+ if (cached != null) {
+ return cached[tileIndex];
+ }
+ } else {
+ invalidateWindTileCache(wind, seat, plan);
+ }
+ int handSize = seat.hand().size();
+ long[] fingerprints = new long[handSize];
+ for (int i = 0; i < handSize; i++) {
+ fingerprints[i] = computeHandPublicTileFingerprint(snapshot, seat, plan, i);
+ }
+ this.cachedHandPublicTileFingerprints[wind] = fingerprints;
+ return fingerprints[tileIndex];
+ }
+
+ private long computeHandPublicTileFingerprint(
+ TableRenderSnapshot snapshot,
+ TableSeatRenderSnapshot seat,
+ TableRenderLayout.SeatLayoutPlan plan,
+ int tileIndex
) {
TableRenderLayout.Point point = plan.publicHandPoints().get(tileIndex);
return fingerprintBuilder(160)
@@ -74,6 +177,25 @@ public long handPublicTileFingerprint(
}
public long discardTileFingerprint(TableSeatRenderSnapshot seat, TableRenderLayout.SeatLayoutPlan plan, int discardIndex) {
+ int wind = seat.wind().ordinal();
+ if (this.cachedTileSeat[wind] == seat && this.cachedTilePlan[wind] == plan) {
+ long[] cached = this.cachedDiscardTileFingerprints[wind];
+ if (cached != null) {
+ return cached[discardIndex];
+ }
+ } else {
+ invalidateWindTileCache(wind, seat, plan);
+ }
+ int discardCount = plan.discardPlacements().size();
+ long[] fingerprints = new long[discardCount];
+ for (int i = 0; i < discardCount; i++) {
+ fingerprints[i] = computeDiscardTileFingerprint(seat, plan, i);
+ }
+ this.cachedDiscardTileFingerprints[wind] = fingerprints;
+ return fingerprints[discardIndex];
+ }
+
+ private long computeDiscardTileFingerprint(TableSeatRenderSnapshot seat, TableRenderLayout.SeatLayoutPlan plan, int discardIndex) {
TableRenderLayout.TilePlacement placement = plan.discardPlacements().get(discardIndex);
return fingerprintBuilder(160)
.field("discard-tile")
@@ -91,6 +213,23 @@ public long discardTileFingerprint(TableSeatRenderSnapshot seat, TableRenderLayo
}
long wallTileFingerprint(TableRenderLayout.LayoutPlan plan, int wallIndex) {
+ if (this.cachedWallLayoutPlan == plan) {
+ long[] cached = this.cachedWallTileFingerprints;
+ if (cached != null) {
+ return cached[wallIndex];
+ }
+ }
+ int wallSize = plan.wallTiles().size();
+ long[] fingerprints = new long[wallSize];
+ for (int i = 0; i < wallSize; i++) {
+ fingerprints[i] = computeWallTileFingerprint(plan, i);
+ }
+ this.cachedWallLayoutPlan = plan;
+ this.cachedWallTileFingerprints = fingerprints;
+ return fingerprints[wallIndex];
+ }
+
+ private long computeWallTileFingerprint(TableRenderLayout.LayoutPlan plan, int wallIndex) {
TableRenderLayout.TilePlacement placement = plan.wallTiles().get(wallIndex);
if (placement == null) {
return fingerprintBuilder(32)
@@ -219,6 +358,25 @@ private long handPublicFingerprint(TableRenderSnapshot snapshot, TableSeatRender
}
public long meldTileFingerprint(TableSeatRenderSnapshot seat, TableRenderLayout.SeatLayoutPlan plan, int meldIndex) {
+ int wind = seat.wind().ordinal();
+ if (this.cachedTileSeat[wind] == seat && this.cachedTilePlan[wind] == plan) {
+ long[] cached = this.cachedMeldTileFingerprints[wind];
+ if (cached != null) {
+ return cached[meldIndex];
+ }
+ } else {
+ invalidateWindTileCache(wind, seat, plan);
+ }
+ int meldCount = plan.meldPlacements().size();
+ long[] fingerprints = new long[meldCount];
+ for (int i = 0; i < meldCount; i++) {
+ fingerprints[i] = computeMeldTileFingerprint(seat, plan, i);
+ }
+ this.cachedMeldTileFingerprints[wind] = fingerprints;
+ return fingerprints[meldIndex];
+ }
+
+ private long computeMeldTileFingerprint(TableSeatRenderSnapshot seat, TableRenderLayout.SeatLayoutPlan plan, int meldIndex) {
TableRenderLayout.TilePlacement placement = plan.meldPlacements().get(meldIndex);
return fingerprintBuilder(160)
.field("meld-tile")
@@ -254,6 +412,24 @@ private String seatRegionKey(String region, SeatWind wind) {
return region + ":" + wind.name();
}
+ /**
+ * Resets the per-wind tile cache when the {@code (seat, plan)} identity
+ * changes. All four method arrays are cleared because they were computed
+ * from the previous {@code (seat, plan)} pair and are no longer valid.
+ */
+ private void invalidateWindTileCache(
+ int wind,
+ TableSeatRenderSnapshot seat,
+ TableRenderLayout.SeatLayoutPlan plan
+ ) {
+ this.cachedTileSeat[wind] = seat;
+ this.cachedTilePlan[wind] = plan;
+ this.cachedHandPrivateTileFingerprints[wind] = null;
+ this.cachedHandPublicTileFingerprints[wind] = null;
+ this.cachedDiscardTileFingerprints[wind] = null;
+ this.cachedMeldTileFingerprints[wind] = null;
+ }
+
private static FingerprintBuilder fingerprintBuilder(int capacity) {
return new FingerprintBuilder();
}