From 2bed25cfddb99ebe5012ff813cebc2019165a15b Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 17:39:53 +0800 Subject: [PATCH] perf(render): apply regions directly with prepared fingerprints --- .../table/core/MahjongTableSession.java | 5 +- .../render/TableRegionDisplayCoordinator.java | 378 ++++++++++++++---- .../render/TableRegionFingerprintService.java | 82 +++- .../TableRegionDisplayCoordinatorTest.kt | 94 +++++ 4 files changed, 488 insertions(+), 71 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java b/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java index 210bd7d..6b7e1fc 100644 --- a/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java +++ b/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java @@ -1635,10 +1635,11 @@ public TableRenderSnapshot captureRenderSnapshot(long version, long cancellation } public TableRenderPrecomputeResult precomputeRender(TableRenderSnapshot snapshot) { + TableRenderLayout.LayoutPlan layout = TableRenderLayout.precompute(snapshot); return new TableRenderPrecomputeResult( snapshot, - this.regionFingerprintService.precomputeRegionFingerprints(this, snapshot), - TableRenderLayout.precompute(snapshot) + this.regionFingerprintService.precomputeRegionFingerprints(this, snapshot, layout), + layout ); } diff --git a/src/main/java/top/ellan/mahjong/table/render/TableRegionDisplayCoordinator.java b/src/main/java/top/ellan/mahjong/table/render/TableRegionDisplayCoordinator.java index 3bb6f37..ffe86fa 100644 --- a/src/main/java/top/ellan/mahjong/table/render/TableRegionDisplayCoordinator.java +++ b/src/main/java/top/ellan/mahjong/table/render/TableRegionDisplayCoordinator.java @@ -41,7 +41,8 @@ public final class TableRegionDisplayCoordinator { private static final int MAX_HAND_TILE_REGIONS = 14; private static final int MAX_DISCARD_TILE_REGIONS = 24; private static final int MAX_MELD_TILE_REGIONS = 20; - private static final int SEAT_COUNT = SeatWind.values().length; + private static final SeatWind[] SEAT_WINDS = SeatWind.values(); + private static final int SEAT_COUNT = SEAT_WINDS.length; private static final int DEFAULT_MAX_REGION_UPDATES_PER_APPLY = 64; private static final int DEFAULT_MAX_ENTITY_SPAWNS_PER_APPLY = 192; private static final String[] VISUAL_REGION_KEYS = createSeatRegionKeys("visual"); @@ -115,66 +116,17 @@ public boolean applyRenderPrecompute(TableRenderPrecomputeResult result) { TableRenderSnapshot snapshot = result.snapshot(); TableRenderLayout.LayoutPlan plan = result.layout(); Map fingerprints = result.regionFingerprints(); - RegionUpdateQueue queue = new RegionUpdateQueue(); + long planStartedAt = System.nanoTime(); + int plannedUpdates = this.prepareRegionUpdates(snapshot, plan); + metrics.recordTimerNanos("table.render.region.plan.nanos", System.nanoTime() - planStartedAt); + metrics.recordGauge("table.render.region.queue.size", plannedUpdates); - this.enqueue(queue, BUCKET_BOARD, () -> this.updateStaticRegion( - REGION_TABLE, - fingerprintOf(fingerprints, REGION_TABLE), - budget, - () -> this.session.renderer().renderTableStructure(this.session, plan) - )); - this.enqueueWallRegionUpdates(plan, budget, queue); - this.enqueue(queue, BUCKET_BOARD, () -> this.updateRegionWithSpecs( - REGION_DORA, - fingerprintOf(fingerprints, REGION_DORA), - budget, - () -> this.session.renderer().renderDoraSpecs(this.session, plan) - )); - this.enqueue(queue, BUCKET_REACTION_PROMPT, () -> this.updateRegionWithSpecs( - REGION_CENTER, - fingerprintOf(fingerprints, REGION_CENTER), - budget, - () -> this.session.renderer().renderCenterLabelSpecs(this.session, snapshot, plan) - )); - - for (SeatWind wind : SeatWind.values()) { - TableSeatRenderSnapshot seat = snapshot.seat(wind); - TableRenderLayout.SeatLayoutPlan seatPlan = plan.seat(wind); - String visualRegionKey = this.seatRegionKey("visual", wind); - String labelsRegionKey = this.seatRegionKey("labels", wind); - String sticksRegionKey = this.seatRegionKey("sticks", wind); - this.enqueue(queue, BUCKET_BACKGROUND, () -> this.updateStaticRegion( - visualRegionKey, - fingerprintOf(fingerprints, visualRegionKey), - budget, - () -> this.session.renderer().renderSeatVisual(this.session, wind) - )); - this.enqueue(queue, BUCKET_REACTION_PROMPT, () -> this.updateSeatLabelRegion( - labelsRegionKey, - fingerprintOf(fingerprints, labelsRegionKey), - budget, - snapshot, - seat, - seatPlan - )); - this.enqueue(queue, BUCKET_TURN_STATE, () -> this.updateRegion( - sticksRegionKey, - fingerprintOf(fingerprints, sticksRegionKey), - budget, - () -> this.session.renderer().renderSticks(this.session, seat, seatPlan) - )); - this.enqueuePublicHandRegionUpdates(snapshot, seat, seatPlan, budget, queue); - this.enqueuePrivateHandRegionUpdates(seat, seatPlan, budget, queue); - this.enqueueDiscardRegionUpdates(seat, seatPlan, budget, queue); - this.enqueueMeldRegionUpdates(seat, seatPlan, budget, queue); - } - - metrics.recordGauge("table.render.region.queue.size", queue.size()); - QueueExecution execution = this.applyQueue(queue); + QueueExecution execution = this.applyDirect(snapshot, plan, fingerprints, budget); metrics.incrementCounter("table.render.region.apply.processed", execution.processedUpdates()); + metrics.incrementCounter("table.render.region.apply.skipped", budget.skippedRegionUpdates()); if (execution.deferred()) { metrics.incrementCounter("table.render.region.apply.deferred"); - metrics.recordGauge("table.render.region.queue.remaining", queue.size() - execution.processedUpdates()); + metrics.recordGauge("table.render.region.queue.remaining", plannedUpdates - execution.processedUpdates()); } else { metrics.recordGauge("table.render.region.queue.remaining", 0L); } @@ -474,6 +426,281 @@ public boolean hasStaleDisplayRegions() { return false; } + /** Preserves eager clears while avoiding action objects and bucket lists. */ + int prepareRegionUpdates(TableRenderSnapshot snapshot, TableRenderLayout.LayoutPlan plan) { + int plannedUpdates = 3; + this.clearRegion(REGION_WALL); + int wallTileCount = boundedCount(plan.wallTiles().size(), MAX_WALL_TILE_REGIONS); + plannedUpdates += wallTileCount; + for (int wallIndex = wallTileCount; wallIndex < MAX_WALL_TILE_REGIONS; wallIndex++) { + this.clearRegion(wallRegionKey(wallIndex)); + } + + for (SeatWind wind : SEAT_WINDS) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = plan.seat(wind); + this.clearRegion(seatRegionKey("hand-public", wind)); + this.clearRegion(seatRegionKey("hand-private", wind)); + this.clearRegion(seatRegionKey("discards", wind)); + this.clearRegion(seatRegionKey("melds", wind)); + + int handSize = seat.playerId() == null ? 0 : boundedCount(seat.hand().size(), MAX_HAND_TILE_REGIONS); + int discardCount = seat.playerId() == null + ? 0 + : boundedCount(seatPlan.discardPlacements().size(), MAX_DISCARD_TILE_REGIONS); + int meldCount = seat.playerId() == null + ? 0 + : boundedCount(seatPlan.meldPlacements().size(), MAX_MELD_TILE_REGIONS); + plannedUpdates += 3 + handSize * 2 + discardCount + meldCount; + + for (int tileIndex = handSize; tileIndex < MAX_HAND_TILE_REGIONS; tileIndex++) { + this.clearRegion(handPublicRegionKey(wind, tileIndex)); + this.clearRegion(handPrivateRegionKey(wind, tileIndex)); + } + for (int discardIndex = discardCount; discardIndex < MAX_DISCARD_TILE_REGIONS; discardIndex++) { + this.clearRegion(discardRegionKey(wind, discardIndex)); + } + for (int meldIndex = meldCount; meldIndex < MAX_MELD_TILE_REGIONS; meldIndex++) { + this.clearRegion(meldRegionKey(wind, meldIndex)); + } + } + return plannedUpdates; + } + + private QueueExecution applyDirect( + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan plan, + Map fingerprints, + ApplyBudget budget + ) { + ApplyProgress progress = new ApplyProgress(); + if (!this.applyReactionPromptUpdates(snapshot, plan, fingerprints, budget, progress) + || !this.applyHandUpdates(snapshot, plan, fingerprints, budget, progress) + || !this.applyTurnStateUpdates(snapshot, plan, fingerprints, budget, progress) + || !this.applyBoardUpdates(snapshot, plan, fingerprints, budget, progress) + || !this.applyBackgroundUpdates(snapshot, plan, fingerprints, budget, progress)) { + return new QueueExecution(true, progress.processedUpdates); + } + return new QueueExecution(false, progress.processedUpdates); + } + + private boolean applyReactionPromptUpdates( + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan plan, + Map fingerprints, + ApplyBudget budget, + ApplyProgress progress + ) { + if (!recordApplied(this.updateRegionWithSpecs( + REGION_CENTER, + fingerprintOf(fingerprints, REGION_CENTER), + budget, + () -> this.session.renderer().renderCenterLabelSpecs(this.session, snapshot, plan) + ), progress)) { + return false; + } + for (SeatWind wind : SEAT_WINDS) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = plan.seat(wind); + String labelsRegionKey = seatRegionKey("labels", wind); + if (!recordApplied(this.updateSeatLabelRegion( + labelsRegionKey, + fingerprintOf(fingerprints, labelsRegionKey), + budget, + snapshot, + seat, + seatPlan + ), progress)) { + return false; + } + } + return true; + } + + private boolean applyHandUpdates( + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan plan, + Map fingerprints, + ApplyBudget budget, + ApplyProgress progress + ) { + for (SeatWind wind : SEAT_WINDS) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = plan.seat(wind); + int handSize = seat.playerId() == null ? 0 : boundedCount(seat.hand().size(), MAX_HAND_TILE_REGIONS); + for (int tileIndex = 0; tileIndex < handSize; tileIndex++) { + int index = tileIndex; + String regionKey = handPublicRegionKey(wind, index); + Long prepared = fingerprints.get(regionKey); + long fingerprint = prepared == null + ? this.fingerprintService.handPublicTileFingerprint(snapshot, seat, seatPlan, index) + : prepared; + if (!recordApplied(this.updateRegionWithSpecs( + regionKey, + fingerprint, + budget, + () -> this.session.renderer().renderHandPublicTileSpecs(this.session, snapshot, seat, seatPlan, index) + ), progress)) { + return false; + } + } + for (int tileIndex = 0; tileIndex < handSize; tileIndex++) { + int index = tileIndex; + String regionKey = handPrivateRegionKey(wind, index); + Long prepared = fingerprints.get(regionKey); + long fingerprint = prepared == null + ? this.fingerprintService.handPrivateTileFingerprint(seat, seatPlan, index) + : prepared; + if (!recordApplied(this.updatePrivateHandTileRegion( + regionKey, + fingerprint, + budget, + seat, + seatPlan, + index + ), progress)) { + return false; + } + } + } + return true; + } + + private boolean applyTurnStateUpdates( + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan plan, + Map fingerprints, + ApplyBudget budget, + ApplyProgress progress + ) { + for (SeatWind wind : SEAT_WINDS) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = plan.seat(wind); + String sticksRegionKey = seatRegionKey("sticks", wind); + if (!recordApplied(this.updateRegion( + sticksRegionKey, + fingerprintOf(fingerprints, sticksRegionKey), + budget, + () -> this.session.renderer().renderSticks(this.session, seat, seatPlan) + ), progress)) { + return false; + } + + int discardCount = seat.playerId() == null + ? 0 + : boundedCount(seatPlan.discardPlacements().size(), MAX_DISCARD_TILE_REGIONS); + for (int discardIndex = 0; discardIndex < discardCount; discardIndex++) { + int index = discardIndex; + String regionKey = discardRegionKey(wind, index); + Long prepared = fingerprints.get(regionKey); + long fingerprint = prepared == null + ? this.fingerprintService.discardTileFingerprint(seat, seatPlan, index) + : prepared; + if (!recordApplied(this.updateRegionWithSpecs( + regionKey, + fingerprint, + budget, + () -> this.session.renderer().renderDiscardTileSpecs(this.session, seat, seatPlan, index) + ), progress)) { + return false; + } + } + + int meldCount = seat.playerId() == null + ? 0 + : boundedCount(seatPlan.meldPlacements().size(), MAX_MELD_TILE_REGIONS); + for (int meldIndex = 0; meldIndex < meldCount; meldIndex++) { + int index = meldIndex; + String regionKey = meldRegionKey(wind, index); + Long prepared = fingerprints.get(regionKey); + long fingerprint = prepared == null + ? this.fingerprintService.meldTileFingerprint(seat, seatPlan, index) + : prepared; + if (!recordApplied(this.updateRegionWithSpecs( + regionKey, + fingerprint, + budget, + () -> this.session.renderer().renderMeldTileSpecs(this.session, seat, seatPlan, index) + ), progress)) { + return false; + } + } + } + return true; + } + + private boolean applyBoardUpdates( + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan plan, + Map fingerprints, + ApplyBudget budget, + ApplyProgress progress + ) { + if (!recordApplied(this.updateStaticRegion( + REGION_TABLE, + fingerprintOf(fingerprints, REGION_TABLE), + budget, + () -> this.session.renderer().renderTableStructure(this.session, plan) + ), progress)) { + return false; + } + return recordApplied(this.updateRegionWithSpecs( + REGION_DORA, + fingerprintOf(fingerprints, REGION_DORA), + budget, + () -> this.session.renderer().renderDoraSpecs(this.session, plan) + ), progress); + } + + private boolean applyBackgroundUpdates( + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan plan, + Map fingerprints, + ApplyBudget budget, + ApplyProgress progress + ) { + int wallTileCount = boundedCount(plan.wallTiles().size(), MAX_WALL_TILE_REGIONS); + for (int wallIndex = 0; wallIndex < wallTileCount; wallIndex++) { + int index = wallIndex; + String regionKey = wallRegionKey(index); + Long prepared = fingerprints.get(regionKey); + long fingerprint = prepared == null + ? this.fingerprintService.wallTileFingerprint(plan, index) + : prepared; + if (!recordApplied(this.updateRegionWithSpecs( + regionKey, + fingerprint, + budget, + () -> this.session.renderer().renderWallTileSpecs(this.session, plan, index) + ), progress)) { + return false; + } + } + for (SeatWind wind : SEAT_WINDS) { + String visualRegionKey = seatRegionKey("visual", wind); + if (!recordApplied(this.updateStaticRegion( + visualRegionKey, + fingerprintOf(fingerprints, visualRegionKey), + budget, + () -> this.session.renderer().renderSeatVisual(this.session, wind) + ), progress)) { + return false; + } + } + return true; + } + + private static int boundedCount(int count, int maximum) { + return Math.max(0, Math.min(count, maximum)); + } + + private static boolean recordApplied(boolean applied, ApplyProgress progress) { + if (applied) { + progress.processedUpdates++; + } + return applied; + } + private QueueExecution applyQueue(RegionUpdateQueue updates) { int processed = 0; for (int bucketIndex = 0; bucketIndex < updates.bucketCount(); bucketIndex++) { @@ -685,6 +912,7 @@ private boolean updateRegionWithRayInteractions( && previousFingerprint == fingerprint && (currentEntities != null || this.regionFingerprints.containsKey(regionKey)) && (!requiresRayInteractions || this.rayInteractionsCurrent(regionKey))) { + budget.recordSkippedRegionUpdate(); return true; } if (!budget.canConsumeRegionUpdate(1)) { @@ -816,6 +1044,7 @@ private boolean updateRegion(String regionKey, long fingerprint, ApplyBudget bud currentEntities = null; } if (previousFingerprint != null && previousFingerprint == fingerprint && (currentEntities != null || this.regionFingerprints.containsKey(regionKey))) { + budget.recordSkippedRegionUpdate(); return true; } if (!budget.tryConsumeRegionUpdate(1)) { @@ -834,6 +1063,7 @@ private boolean updateStaticRegion(String regionKey, long fingerprint, ApplyBudg List currentEntities = this.regionDisplays.get(regionKey); if (!this.hasInvalidDisplayEntity(currentEntities) && currentEntities != null) { this.regionFingerprints.put(regionKey, fingerprint); + budget.recordSkippedRegionUpdate(); return true; } return this.updateRegion(regionKey, fingerprint, budget, renderer); @@ -848,6 +1078,7 @@ private boolean updateRegionWithSpecs(String regionKey, long fingerprint, ApplyB currentEntities = null; } if (previousFingerprint != null && previousFingerprint == fingerprint && (currentEntities != null || this.regionFingerprints.containsKey(regionKey))) { + budget.recordSkippedRegionUpdate(); return true; } if (!budget.canConsumeRegionUpdate(1)) { @@ -1016,7 +1247,7 @@ private static long usedHeapBytes() { return runtime.totalMemory() - runtime.freeMemory(); } - private String seatRegionKey(String region, SeatWind wind) { + static String seatRegionKey(String region, SeatWind wind) { if ("visual".equals(region)) { return VISUAL_REGION_KEYS[wind.index()]; } @@ -1041,35 +1272,35 @@ private String seatRegionKey(String region, SeatWind wind) { return region + ":" + wind.name(); } - private String handPrivateRegionKey(SeatWind wind, int tileIndex) { + static String handPrivateRegionKey(SeatWind wind, int tileIndex) { if (tileIndex >= 0 && tileIndex < MAX_HAND_TILE_REGIONS) { return HAND_PRIVATE_TILE_REGION_KEYS[wind.index()][tileIndex]; } - return this.seatRegionKey("hand-private-" + tileIndex, wind); + return seatRegionKey("hand-private-" + tileIndex, wind); } - private String handPublicRegionKey(SeatWind wind, int tileIndex) { + static String handPublicRegionKey(SeatWind wind, int tileIndex) { if (tileIndex >= 0 && tileIndex < MAX_HAND_TILE_REGIONS) { return HAND_PUBLIC_TILE_REGION_KEYS[wind.index()][tileIndex]; } - return this.seatRegionKey("hand-public-" + tileIndex, wind); + return seatRegionKey("hand-public-" + tileIndex, wind); } - private String discardRegionKey(SeatWind wind, int discardIndex) { + static String discardRegionKey(SeatWind wind, int discardIndex) { if (discardIndex >= 0 && discardIndex < MAX_DISCARD_TILE_REGIONS) { return DISCARD_TILE_REGION_KEYS[wind.index()][discardIndex]; } - return this.seatRegionKey("discards-" + discardIndex, wind); + return seatRegionKey("discards-" + discardIndex, wind); } - private String meldRegionKey(SeatWind wind, int meldIndex) { + static String meldRegionKey(SeatWind wind, int meldIndex) { if (meldIndex >= 0 && meldIndex < MAX_MELD_TILE_REGIONS) { return MELD_TILE_REGION_KEYS[wind.index()][meldIndex]; } - return this.seatRegionKey("melds-" + meldIndex, wind); + return seatRegionKey("melds-" + meldIndex, wind); } - private String wallRegionKey(int wallIndex) { + static String wallRegionKey(int wallIndex) { if (wallIndex >= 0 && wallIndex < MAX_WALL_TILE_REGIONS) { return WALL_TILE_REGION_KEYS[wallIndex]; } @@ -1132,6 +1363,7 @@ private interface RegionUpdateAction { private static final class ApplyBudget { private int remainingRegionUpdates; private int remainingEntitySpawns; + private int skippedRegionUpdates; private ApplyBudget(int remainingRegionUpdates, int remainingEntitySpawns) { this.remainingRegionUpdates = remainingRegionUpdates; @@ -1173,6 +1405,18 @@ void consumeRegionUpdate(int amount) { void consumeEntitySpawns(int amount) { this.remainingEntitySpawns -= amount; } + + void recordSkippedRegionUpdate() { + this.skippedRegionUpdates++; + } + + int skippedRegionUpdates() { + return this.skippedRegionUpdates; + } + } + + private static final class ApplyProgress { + private int processedUpdates; } private static final class RegionUpdateQueue { 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..2fe1a11 100644 --- a/src/main/java/top/ellan/mahjong/table/render/TableRegionFingerprintService.java +++ b/src/main/java/top/ellan/mahjong/table/render/TableRegionFingerprintService.java @@ -5,6 +5,7 @@ import top.ellan.mahjong.render.layout.TableRenderLayout; import top.ellan.mahjong.render.snapshot.TableRenderSnapshot; import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -14,9 +15,19 @@ public final class TableRegionFingerprintService { private static final String REGION_WALL = "wall"; private static final String REGION_DORA = "dora"; private static final String REGION_CENTER = "center"; + private static final int BASE_REGION_COUNT = 4 + SeatWind.values().length * 4; public Map precomputeRegionFingerprints(TableRenderSubject session, TableRenderSnapshot snapshot) { - Map fingerprints = new HashMap<>(); + Map fingerprints = new HashMap<>(hashMapCapacity(BASE_REGION_COUNT)); + this.populateBaseRegionFingerprints(fingerprints, session, snapshot); + return Map.copyOf(fingerprints); + } + + private void populateBaseRegionFingerprints( + Map fingerprints, + TableRenderSubject session, + TableRenderSnapshot snapshot + ) { fingerprints.put(REGION_TABLE, this.tableFingerprint(session, snapshot)); fingerprints.put(REGION_WALL, this.wallFingerprint(snapshot)); fingerprints.put(REGION_DORA, this.doraFingerprint(snapshot)); @@ -28,7 +39,74 @@ 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); + } + + /** Precomputes every active tile fingerprint while still on the async render thread. */ + public Map precomputeRegionFingerprints( + TableRenderSubject session, + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan layout + ) { + int exactRegionCount = layout.wallTiles().size(); + for (SeatWind wind : SeatWind.values()) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = layout.seat(wind); + if (seat.playerId() != null) { + exactRegionCount += Math.min( + seat.hand().size(), + Math.min(seatPlan.publicHandPoints().size(), seatPlan.privateHandPoints().size()) + ) * 2; + exactRegionCount += seatPlan.discardPlacements().size(); + exactRegionCount += seatPlan.meldPlacements().size(); + } + } + Map fingerprints = new HashMap<>(hashMapCapacity(BASE_REGION_COUNT + exactRegionCount)); + this.populateBaseRegionFingerprints(fingerprints, session, snapshot); + for (SeatWind wind : SeatWind.values()) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = layout.seat(wind); + int handSize = seat.playerId() == null + ? 0 + : Math.min(seat.hand().size(), Math.min(seatPlan.publicHandPoints().size(), seatPlan.privateHandPoints().size())); + for (int tileIndex = 0; tileIndex < handSize; tileIndex++) { + fingerprints.put( + TableRegionDisplayCoordinator.handPublicRegionKey(wind, tileIndex), + this.handPublicTileFingerprint(snapshot, seat, seatPlan, tileIndex) + ); + fingerprints.put( + TableRegionDisplayCoordinator.handPrivateRegionKey(wind, tileIndex), + this.handPrivateTileFingerprint(seat, seatPlan, tileIndex) + ); + } + int discardCount = seat.playerId() == null ? 0 : seatPlan.discardPlacements().size(); + for (int discardIndex = 0; discardIndex < discardCount; discardIndex++) { + fingerprints.put( + TableRegionDisplayCoordinator.discardRegionKey(wind, discardIndex), + this.discardTileFingerprint(seat, seatPlan, discardIndex) + ); + } + int meldCount = seat.playerId() == null ? 0 : seatPlan.meldPlacements().size(); + for (int meldIndex = 0; meldIndex < meldCount; meldIndex++) { + fingerprints.put( + TableRegionDisplayCoordinator.meldRegionKey(wind, meldIndex), + this.meldTileFingerprint(seat, seatPlan, meldIndex) + ); + } + } + for (int wallIndex = 0; wallIndex < layout.wallTiles().size(); wallIndex++) { + fingerprints.put( + TableRegionDisplayCoordinator.wallRegionKey(wallIndex), + this.wallTileFingerprint(layout, wallIndex) + ); + } + // The backing map is method-local and never escapes independently, so the read-only view + // avoids duplicating every entry after async precomputation while remaining immutable to + // render consumers. + return Collections.unmodifiableMap(fingerprints); + } + + private static int hashMapCapacity(int entryCount) { + return Math.max(16, (int) Math.ceil(entryCount / 0.75D)); } public long handPrivateTileFingerprint(TableSeatRenderSnapshot seat, TableRenderLayout.SeatLayoutPlan plan, int tileIndex) { diff --git a/src/test/kotlin/top/ellan/mahjong/table/render/TableRegionDisplayCoordinatorTest.kt b/src/test/kotlin/top/ellan/mahjong/table/render/TableRegionDisplayCoordinatorTest.kt index 4af43e0..fc9c820 100644 --- a/src/test/kotlin/top/ellan/mahjong/table/render/TableRegionDisplayCoordinatorTest.kt +++ b/src/test/kotlin/top/ellan/mahjong/table/render/TableRegionDisplayCoordinatorTest.kt @@ -6,6 +6,7 @@ import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.eq import org.mockito.Mockito.mock import org.mockito.Mockito.`when` +import top.ellan.mahjong.config.PluginSettings import top.ellan.mahjong.metrics.InMemoryMetricsCollector import top.ellan.mahjong.model.MahjongTile import top.ellan.mahjong.model.SeatWind @@ -273,7 +274,17 @@ class TableRegionDisplayCoordinatorTest { assertEquals(16L, metrics.counterValue("table.render.region.apply.processed")) assertEquals(1L, metrics.counterValue("table.render.region.apply.deferred")) assertTrue(metrics.gaugeValue("table.render.region.queue.size") >= 16L) + assertTrue(metrics.timerCount("table.render.region.plan.nanos") >= 1L) assertTrue(metrics.timerCount("table.render.region.apply.nanos") >= 1L) + + assertFalse(coordinator.applyRenderPrecompute(precomputeResult())) + assertEquals(listOf("hand:private", "background:visual:NORTH"), calls.takeLast(2)) + assertEquals(18, calls.size, "Only the private ray and deferred region should render on the retry tick.") + assertEquals(33L, metrics.counterValue("table.render.region.apply.processed")) + assertEquals(15L, metrics.counterValue("table.render.region.apply.skipped")) + assertEquals(1L, metrics.counterValue("table.render.region.apply.deferred")) + assertEquals(0L, metrics.gaugeValue("table.render.region.queue.remaining")) + assertTrue(metrics.timerCount("table.render.region.plan.nanos") >= 2L) } @Test @@ -343,6 +354,89 @@ class TableRegionDisplayCoordinatorTest { assertEquals(0L, metrics.gaugeValue("table.render.region.viewer_overlay_entities")) } + @Test + fun `complete async fingerprint map preserves exact per-region values`() { + val session = mock(MahjongTableSession::class.java) + `when`(session.settings()).thenReturn(PluginSettings.defaults()) + val service = TableRegionFingerprintService() + val basic = precomputeResult() + val east = basic.layout().seat(SeatWind.EAST) + val discardPlacement = + TableRenderLayout.TilePlacement( + point(), + 0.0F, + MahjongTile.EAST, + DisplayEntities.TileRenderPose.FLAT_FACE_UP, + ) + val meldPlacement = + TableRenderLayout.TilePlacement( + point(), + 90.0F, + MahjongTile.P2, + DisplayEntities.TileRenderPose.FLAT_FACE_UP, + ) + val wallPlacement = + TableRenderLayout.TilePlacement( + point(), + 180.0F, + MahjongTile.UNKNOWN, + DisplayEntities.TileRenderPose.FLAT_FACE_DOWN, + ) + val seatPlans = EnumMap(basic.layout().seats()) + seatPlans[SeatWind.EAST] = + TableRenderLayout.SeatLayoutPlan( + east.wind(), + east.handBase(), + east.statusLabelLocation(), + east.playerNameLocation(), + east.interactionLocation(), + east.yaw(), + east.publicHandPoints(), + east.privateHandPoints(), + listOf(discardPlacement), + listOf(meldPlacement), + east.stickPlacements(), + ) + val layout = + TableRenderLayout.LayoutPlan( + basic.layout().displayCenter(), + basic.layout().tableCenter(), + basic.layout().tableVisualAnchor(), + basic.layout().borderSpanX(), + basic.layout().borderSpanZ(), + seatPlans, + listOf(wallPlacement), + basic.layout().doraTiles(), + ) + + val coarse = service.precomputeRegionFingerprints(session, basic.snapshot()) + val complete = service.precomputeRegionFingerprints(session, basic.snapshot(), layout) + coarse.forEach { (key, value) -> assertEquals(value, complete[key], key) } + + val eastSnapshot = basic.snapshot().seat(SeatWind.EAST) + assertEquals( + service.handPublicTileFingerprint(basic.snapshot(), eastSnapshot, layout.seat(SeatWind.EAST), 0), + complete[TableRegionDisplayCoordinator.handPublicRegionKey(SeatWind.EAST, 0)], + ) + assertEquals( + service.handPrivateTileFingerprint(eastSnapshot, layout.seat(SeatWind.EAST), 0), + complete[TableRegionDisplayCoordinator.handPrivateRegionKey(SeatWind.EAST, 0)], + ) + assertEquals( + service.discardTileFingerprint(eastSnapshot, layout.seat(SeatWind.EAST), 0), + complete[TableRegionDisplayCoordinator.discardRegionKey(SeatWind.EAST, 0)], + ) + assertEquals( + service.meldTileFingerprint(eastSnapshot, layout.seat(SeatWind.EAST), 0), + complete[TableRegionDisplayCoordinator.meldRegionKey(SeatWind.EAST, 0)], + ) + assertEquals( + service.wallTileFingerprint(layout, 0), + complete[TableRegionDisplayCoordinator.wallRegionKey(0)], + ) + assertEquals(coarse.size + 5, complete.size) + } + private fun precomputeResult(): TableRenderPrecomputeResult { val eastPlayerId = UUID.fromString("00000000-0000-0000-0000-00000000e001") val seatSnapshots = EnumMap(SeatWind::class.java)