Fix text staying blurry on zoom by ensuring the first setZoomBucket call always applies (PixiJS v8 defaults to auto-resolution which skipped the initial bucket=1 assignment), listening on both 'moved' and 'zoomed' viewport events, and tightening bucket thresholds from 6 to 9 levels.
133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
/**
|
|
* textSharpness — zoom-bucket-aware text rendering.
|
|
*
|
|
* Tracks the viewport zoom level and groups it into discrete buckets.
|
|
* When the bucket changes, visible text/sticky sprites are re-rasterized
|
|
* at the new resolution for crisp rendering. Small zoom movements within
|
|
* the same bucket are ignored to avoid unnecessary texture churn.
|
|
*
|
|
* This does NOT mutate scene data (fontSize stays stable).
|
|
* It only changes the render resolution of text textures.
|
|
*/
|
|
|
|
import type { Viewport } from 'pixi-viewport';
|
|
import type { SceneManager, SceneItem } from './SceneManager';
|
|
import { getItemWorldBounds } from './SceneManager';
|
|
import { TextSprite } from './sprites/TextSprite';
|
|
import { StickySprite } from './sprites/StickySprite';
|
|
|
|
/**
|
|
* Map continuous zoom to discrete bucket.
|
|
* Fewer buckets = fewer re-rasterizations = better perf.
|
|
*/
|
|
export function getTextZoomBucket(scale: number): number {
|
|
if (scale < 0.3) return 0.25;
|
|
if (scale < 0.5) return 0.5;
|
|
if (scale < 0.8) return 0.75;
|
|
if (scale < 1.1) return 1;
|
|
if (scale < 1.4) return 1.25;
|
|
if (scale < 1.8) return 1.5;
|
|
if (scale < 2.2) return 2;
|
|
if (scale < 3.0) return 2.5;
|
|
return 3;
|
|
}
|
|
|
|
export class TextSharpnessManager {
|
|
private _viewport: Viewport;
|
|
private _scene: SceneManager;
|
|
private _currentBucket = 1;
|
|
|
|
constructor(viewport: Viewport, scene: SceneManager) {
|
|
this._viewport = viewport;
|
|
this._scene = scene;
|
|
this._currentBucket = getTextZoomBucket(viewport.scale.x);
|
|
// Apply initial bucket to all existing text/sticky items
|
|
this._refreshAll();
|
|
}
|
|
|
|
/**
|
|
* Call on viewport zoom/move. Returns true if bucket changed.
|
|
*/
|
|
check(): boolean {
|
|
const bucket = getTextZoomBucket(this._viewport.scale.x);
|
|
if (bucket === this._currentBucket) return false;
|
|
this._currentBucket = bucket;
|
|
this._refreshVisible();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Apply current zoom bucket to a single item.
|
|
* Call this when a new text/sticky sprite is created.
|
|
*/
|
|
applyToItem(item: SceneItem): void {
|
|
if (item.displayObject instanceof TextSprite) {
|
|
item.displayObject.setZoomBucket(this._currentBucket);
|
|
} else if (item.displayObject instanceof StickySprite) {
|
|
item.displayObject.setZoomBucket(this._currentBucket);
|
|
}
|
|
}
|
|
|
|
/** Refresh all text/sticky items (used on initial setup). */
|
|
private _refreshAll(): void {
|
|
const bucket = this._currentBucket;
|
|
for (const item of this._scene.getAllItems()) {
|
|
if (item.type !== 'text' && item.type !== 'sticky') continue;
|
|
if (item.displayObject instanceof TextSprite) {
|
|
item.displayObject.setZoomBucket(bucket);
|
|
} else if (item.displayObject instanceof StickySprite) {
|
|
item.displayObject.setZoomBucket(bucket);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Refresh visible text/sticky items at current bucket (on zoom change). */
|
|
private _refreshVisible(): void {
|
|
const bucket = this._currentBucket;
|
|
const items = this._scene.getAllItems();
|
|
const vb = this._getViewportWorldBounds();
|
|
|
|
for (const item of items) {
|
|
if (item.type !== 'text' && item.type !== 'sticky') continue;
|
|
// Skip off-screen items
|
|
if (vb && !this._intersects(item, vb)) continue;
|
|
|
|
if (item.displayObject instanceof TextSprite) {
|
|
item.displayObject.setZoomBucket(bucket);
|
|
} else if (item.displayObject instanceof StickySprite) {
|
|
item.displayObject.setZoomBucket(bucket);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Get viewport bounds in world coordinates. */
|
|
private _getViewportWorldBounds(): { x: number; y: number; w: number; h: number } | null {
|
|
const vp = this._viewport;
|
|
if (!vp.screenWidth || !vp.screenHeight) return null;
|
|
const tl = vp.toWorld(0, 0);
|
|
const br = vp.toWorld(vp.screenWidth, vp.screenHeight);
|
|
return { x: tl.x, y: tl.y, w: br.x - tl.x, h: br.y - tl.y };
|
|
}
|
|
|
|
/** Check if item intersects viewport bounds (with margin). Uses world bounds for group children. */
|
|
private _intersects(item: SceneItem, vb: { x: number; y: number; w: number; h: number }): boolean {
|
|
const margin = 200; // world-space margin to include near-screen items
|
|
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
|
return (
|
|
ix + iw >= vb.x - margin &&
|
|
iy + ih >= vb.y - margin &&
|
|
ix <= vb.x + vb.w + margin &&
|
|
iy <= vb.y + vb.h + margin
|
|
);
|
|
}
|
|
|
|
/** Current bucket value (for initial setup of new items). */
|
|
get currentBucket(): number {
|
|
return this._currentBucket;
|
|
}
|
|
|
|
destroy(): void {
|
|
// nothing to clean up currently
|
|
}
|
|
}
|