Files
feuerwerk/src/viewport/WorldViewport.ts
T
adminandClaude Opus 5.5 e933fad446 Give a shader room to draw around the picture it wears (#69)
A shader ran only inside the rectangle of what wears it: the quad
has the size of the sprite, and outside of it no fragment runs. An
outline or a glow built with Sample texture was cut off at the
border. The issue still spoke of Filter.padding, but since #77 the
shader is no filter any more, the limit is the geometry.

- The Output node has a number Padding next to Edge: so many pixels
  of the picture per side the wearer draws beyond it. 0 is the
  default and draws as before, 256 is the most.
- The quad grows and its UVs grow with it, so the picture keeps its
  place and size. In the margin the UVs run on below 0 and above 1.
- Transparent is a fourth Edge mode: outside of the picture there
  is nothing. It is cut in the fragment (texelAt), so it needs no
  copy of the picture with an address mode of its own.
- Edge holds everywhere alike, also for Texture color, Texture
  alpha and unconnected inputs.
- Padding above 0 with Edge on Clamp puts the hint "Padding with
  Clamp smears the border" on the Output. The shader still
  compiles. A chosen Clamp and the default cannot be told apart.
- All four wearers draw the margin. A sprite counts in pixels of
  its frame, a text in its units, a particle source grows every
  particle about its centre.
- A nine-slice sprite gets a ring around its nine fields, as wide
  as so many border pixels. The fields themselves stay.
- Without a picture a pixel is one unit of the wearer's own size,
  or of the particle size.
- Particles now take the Edge mode of their shader. It had no
  effect on them before.
- An object wearing a padded shader is never culled, or its margin
  would vanish at the edge of the screen.
- The preview in the shader tab shows the margin and leaves room
  for it.
- Picking, the selection frame, the size and the collider stay with
  the rectangle without the margin.

Tests:
- Gate on melektaus-atx right before this commit: 216 files,
  3336 tests (3319 + 17), exit 0.
- e2e 28,30,31,37,41 on melektaus-atx: 178 checks, all pass.
  e2e 1-37,47,48,58 ran before the last change (no culling with
  padding): 1281 checks, all pass.
- 17 new unit tests: the compiler (padding, Transparent, the hint)
  and the geometry (quad, nine-slice ring).
- Seven new e2e checks: four at the sprite in module 28, one each
  for text, particles and nine-slice.
- 17 unit counter-probes all fail their tests. Three browser
  counter-probes fail their checks (5, 3 and 1 of them).
- Three screenshots looked at: the sprite with Clamp and margin in
  the scene, the preview in the shader tab, the text.
- The e2e sum of 1687 is counted (1680 + 7), the full suite and
  the smokes did not run.
- Not seen: the hint at the node in a picture, particles and
  nine-slice in a picture, the game view while playing, sprites
  without a picture, vector and pixel art pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:33:20 +02:00

630 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Container, type FederatedPointerEvent, Graphics, type Mesh, Texture } from 'pixi.js';
import { isSymbolType } from '../core/model/AssetTypes';
import { type Extent, scaleOf } from '../core/model/matrix';
import type { Vec2 } from '../core/model/Properties';
import { footprintExtent, footprintOf, placementOf } from '../core/properties/bounds';
import { rectsIntersect } from '../core/properties/camera';
import type { ClipRegion } from '../core/properties/clip';
import type { Project } from '../core/model/Project';
import { type Size, Widget } from '../ui';
import { fontLibrary } from './FontLibrary';
import { drawSlots } from './drawOrder';
import type { DrawItem } from './items';
import { hashId } from '../core/particles/nodes';
import { ParticleField } from './particles';
import { particleLibrary } from './ParticleLibrary';
import { SHEET_EDGE } from '../core/shader/nodes';
import { shaderLibrary } from './ShaderLibrary';
import { type ShaderSource, wornShader } from './shaders';
import { disposeSpriteMesh, drawItem, newSpriteMesh, syncSpriteMesh } from './symbols';
import { TextPicture } from './textPicture';
import { cellCentre, type LayerFocus, TilemapField } from './tilemap';
import { textureLibrary } from './TextureLibrary';
import { gameClock } from '../core/time';
import { type View, worldBounds, worldToScreen } from './view';
/** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */
const CLIP_GHOST_ALPHA = 0.3;
/**
* What a view draws from: the project and word of when it changed. The editor's state is one; a
* released game (#266), which has no editor, hands over its own.
*/
export interface ViewSource {
readonly project: Project;
on(event: 'project' | 'selection' | 'mode' | 'runtime', listener: () => void): unknown;
}
export interface Frame {
items: DrawItem[];
background: number;
}
export abstract class WorldViewport<S extends ViewSource = ViewSource> extends Widget {
protected readonly bg = new Graphics();
protected readonly content = new Container();
protected readonly world = new Container();
/** Ordered by `z`, ties by hierarchy order (see `syncDrawOrder`). */
protected readonly objects = new Container();
/** Editor symbols, always above `objects` so no sprite can hide them whatever the z-order. */
protected readonly overlay = new Container();
protected readonly contentMask = new Graphics();
protected readonly nodes = new Map<string, Graphics>();
/** Text content of text items: a sibling of the item's node, kept next to it in z. */
private readonly texts = new Map<string, TextPicture>();
/** Pictures of sprite items: siblings as well, each right under its node. */
private readonly sprites = new Map<string, Mesh>();
/** The drawn particles of each source, one container – and one draw call – per source. */
private readonly fields = new Map<string, ParticleField>();
/** The meshes of each placed tilemap, one container per layer (#174). */
private readonly tilemaps = new Map<string, TilemapField>();
/**
* What clip areas cut off (#260), as a mask per drawn thing: Pixi lets a mask serve only one, and
* switches a shared one back on as content once any of them lets go of it.
*/
private readonly masks = new Container();
private readonly maskOf = new Map<Container, Graphics>();
/** The cut-off part of a sprite or text, drawn faintly under it while its clip area is open. */
private readonly ghostSprites = new Map<string, Mesh>();
private readonly ghostTexts = new Map<string, TextPicture>();
private visibleCount = 0;
private animated = false;
protected lastItems: DrawItem[] = [];
/**
* What the last full redraw was drawn for: its zoom and view size, and per object node the box
* it is culled by (`null`: never culled, a particle source). What `moveCamera` needs to tell
* whether a pan changes anything but where the world sits.
*/
private drawn: {
scale: number;
w: number;
h: number;
culled: { node: Graphics; extent: Extent | null }[];
} | null = null;
/** Full redraws and camera-only moves since start (test hook). */
private redraws = 0;
private cameraMoves = 0;
private readonly stopFiles: (() => void)[];
protected constructor(protected readonly state: S) {
super();
this.world.addChild(this.masks, this.objects, this.overlay);
this.content.addChild(this.world);
this.addChild(this.bg, this.content, this.contentMask);
this.content.mask = this.contentMask;
this.cursor = 'default';
state.on('project', () => this.redraw());
state.on('selection', () => this.redraw());
state.on('mode', () => this.redraw());
// While a Time shader is on screen the ticker redraws every frame anyway (`animate`).
state.on('runtime', () => {
if (this.visible && !this.animated) this.redraw();
});
// Unconditional: a picture or a typeface arriving fires once per file, not per frame.
this.stopFiles = [
textureLibrary.onChange(() => this.redraw()),
fontLibrary.onChange(() => this.redraw()),
];
}
/** The libraries outlive a signed-out editor; a stale listener would redraw destroyed graphics. */
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
for (const stop of this.stopFiles) stop();
super.destroy(options);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
protected abstract currentView(): View | null;
/** Only called when `currentView()` is non-null. */
protected abstract collectFrame(): Frame;
/** Fills the whole widget, behind and around the view rectangle. */
protected abstract get baseColor(): number;
protected paintsBackground(): boolean {
return true;
}
protected get itemsInteractive(): boolean {
return false;
}
protected beforeItems(_view: View | null): void {}
protected afterItems(_items: DrawItem[], _view: View): void {}
/** `extent` is the world rectangle around the item as drawn, turn and size included. */
protected syncDecorations(_item: DrawItem, _onScreen: boolean, _scale: number, _extent: Extent): void {}
/** An item disappeared: drop whatever `syncDecorations` created for it. */
protected disposeDecorations(_id: string): void {}
/** Whether the item's own drawing is switched off – it is then neither seen nor hit. */
protected hidesShape(_item: DrawItem): boolean {
return false;
}
protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {}
/** Whether the part a clip area cuts off the item is shown faintly and can be grabbed (#260). */
protected opensClip(_item: DrawItem): boolean {
return false;
}
/** Whether the object itself is selected – a selected collider shows its area filled. */
protected isSelected(_objectId: string): boolean {
return false;
}
/** Screen-space drawing that follows the camera, redrawn after a full redraw and after a pan. */
protected drawOnScreen(_items: DrawItem[], _view: View): void {}
debugInfo(): { rendering: boolean; visibleItems: number; redraws: number; cameraMoves: number } {
return {
rendering: this.currentView() !== null,
visibleItems: this.visibleCount,
redraws: this.redraws,
cameraMoves: this.cameraMoves,
};
}
/**
* The camera moved and nothing else did – a pan. Zoom and size are the same, so every node is
* drawn as it was; only the world's position and the screen-space overlays follow. Anything
* else, or an object crossing the edge of the view, takes the full redraw, which would collect,
* redraw and re-upload every shape of a pan frame for nothing.
*/
protected moveCamera(): void {
const view = this.currentView();
const drawn = this.drawn;
if (!view || !drawn || view.scale !== drawn.scale || view.rect.w !== drawn.w || view.rect.h !== drawn.h) {
this.redraw();
return;
}
const bounds = worldBounds(view);
for (const { node, extent } of drawn.culled)
if ((extent === null || rectsIntersect(extent, bounds)) !== node.visible) {
this.redraw();
return;
}
this.cameraMoves++;
this.placeWorld(view);
this.drawOnScreen(this.lastItems, view);
}
private placeWorld(view: View): void {
const { rect } = view;
this.world.position.set(
rect.x + rect.w / 2 - view.x * view.scale,
rect.y + rect.h / 2 - view.y * view.scale,
);
this.world.scale.set(view.scale);
}
/**
* The particles of one source as drawn, in world units, oldest first – what a check reads instead
* of guessing from pixels. `limit` keeps a stream of 500 from becoming 500 lines of JSON.
*/
particleInfo(
objectId: string,
limit = 8,
): {
count: number;
drawn: { x: number; y: number; scaleX: number; alpha: number; tint: number }[];
} | null {
const field = this.fields.get(objectId);
if (!field || !field.container.visible) return null;
return {
count: field.count,
drawn: field.container.particleChildren.slice(0, limit).map((p) => ({
x: Math.round(p.x * 100) / 100,
y: Math.round(p.y * 100) / 100,
scaleX: Math.round(p.scaleX * 1000) / 1000,
alpha: Math.round(p.alpha * 1000) / 1000,
tint: p.tint,
})),
};
}
/**
* Starts every source's clock over at `now`. With particle time frozen this is what makes a
* measurement exact: freeze, restart, move the clock to the second wanted, read.
*/
restartParticles(now: number): void {
for (const field of this.fields.values()) field.restart(now);
this.redraw();
}
/** True after a redraw that drew a sprite wearing a Time shader: the picture moves by itself. */
get animates(): boolean {
return this.animated;
}
/** How often the view was drawn so far; `animate` is told the count its tick began with. */
get redrawCount(): number {
return this.redraws;
}
/**
* Called every tick: redraws while the last redraw found a Time shader on screen. A view drawn
* since the tick began, at `drawnBefore`, already shows this frame – a run draws its views
* itself, and drawing them again cost a second collect of the whole world each frame (#284).
*/
animate(drawnBefore: number): void {
if (this.visible && this.animated && this.redraws === drawnBefore) this.redraw();
}
layerOf(objectId: string): 'objects' | 'overlay' | null {
const node = this.nodes.get(objectId);
if (!node) return null;
return node.parent === this.overlay ? 'overlay' : 'objects';
}
/** Ids in the order they are drawn, back to front. */
drawOrder(): string[] {
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
}
/** Where an object is drawn, in global screen coordinates. */
objectScreen(objectId: string): Vec2 | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item || !view) return null;
const local = worldToScreen(view, item.x, item.y);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
/**
* The screen rectangle around an object, so tests can sample the pixels inside it: centre plus
* size, and for a turned object the box around its corners.
*/
objectRect(objectId: string): { x: number; y: number; w: number; h: number } | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item || !view) return null;
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
const local = worldToScreen(view, (extent.left + extent.right) / 2, (extent.top + extent.bottom) / 2);
const origin = this.getGlobalPosition();
return {
x: origin.x + local.x,
y: origin.y + local.y,
w: (extent.right - extent.left) * view.scale,
h: (extent.bottom - extent.top) * view.scale,
};
}
override redraw(): void {
const view = this.currentView();
this.bg.clear().rect(0, 0, this.w, this.h).fill(this.baseColor);
this.beforeItems(view);
this.contentMask.clear();
this.content.visible = view !== null;
this.visibleCount = 0;
this.animated = false;
this.drawn = null;
if (!view) return;
this.redraws++;
const { items, background } = this.collectFrame();
this.lastItems = items;
const { rect } = view;
if (this.paintsBackground()) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
this.contentMask.rect(rect.x, rect.y, rect.w, rect.h).fill(0xffffff);
this.placeWorld(view);
const bounds = worldBounds(view);
const shaders = shaderLibrary.source(this.state.project);
const culled: { node: Graphics; extent: Extent | null }[] = [];
const seen = new Set<string>();
for (const item of items) {
seen.add(item.id);
const size = footprintOf(item.type, item.props, item.frame);
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
// A switched-off object is not drawn at all, in the scene view as in the game; it is
// selected and switched back on from the hierarchy panel. A source is never culled by its
// own little square: its particles may be anywhere on screen while it is not. Nor is a wearer
// of a padded shader, which draws past its rectangle (#69).
const unbound = item.type === 'particles' || (wornShader(item.props, shaders)?.padding ?? 0) > 0;
const onScreen = item.enabled && (unbound || rectsIntersect(extent, bounds));
let node = this.nodes.get(item.id);
if (!node) {
node = new Graphics();
node.label = item.id;
node.cursor = 'move';
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
this.nodes.set(item.id, node);
(isSymbolType(item.type) ? this.overlay : this.objects).addChild(node);
}
const hidden = this.hidesShape(item);
node.visible = onScreen && !hidden;
if (item.enabled && !hidden) culled.push({ node, extent: unbound ? null : extent });
node.eventMode = this.itemsInteractive && node.visible ? 'static' : 'none';
this.syncDecorations(item, onScreen, view.scale, extent);
this.syncText(item, onScreen, view.scale, shaders);
this.syncSprite(item, onScreen, size, shaders);
this.syncParticles(item, onScreen, shaders);
this.syncTilemap(item, onScreen);
if (!onScreen) continue;
this.visibleCount++;
if (hidden) continue;
drawItem(
node,
item,
size,
view.scale * scaleOf(placementOf(item.type, item.m)),
this.isSelected(item.id),
);
}
this.afterItems(items, view);
this.drawOnScreen(items, view);
this.drawn = { scale: view.scale, w: rect.w, h: rect.h, culled };
for (const [id, node] of this.nodes) {
if (seen.has(id)) continue;
this.texts.get(id)?.destroy();
this.texts.delete(id);
const sprite = this.sprites.get(id);
if (sprite) disposeSpriteMesh(sprite);
this.sprites.delete(id);
this.fields.get(id)?.destroy();
this.fields.delete(id);
this.tilemaps.get(id)?.destroy();
this.tilemaps.delete(id);
this.dropGhosts(id);
node.destroy();
this.nodes.delete(id);
this.disposeDecorations(id);
}
this.syncDrawOrder(items);
this.sweepMasks();
}
/**
* Re-attaches the object nodes in ascending `z`, ties in hierarchy order, each layer of a map
* sorted in on its own (#174, see `drawSlots`). Explicit rather than Pixi's lazy
* `sortableChildren` so the order is deterministic and honours hierarchy changes.
*/
private syncDrawOrder(items: DrawItem[]): void {
this.objects.removeChildren();
const byId = new Map(items.map((item) => [item.id, item]));
for (const { id, layerId, row } of drawSlots(items)) {
const clip = byId.get(id)?.clip ?? null;
if (layerId) {
const layer = this.tilemaps.get(id)?.layerContainer(layerId, row);
if (layer) this.objects.addChild(this.clipTo(layer, clip));
continue;
}
// Picture and text are siblings because Graphics may not have children: the picture right
// under its node, which only holds the hit area, the text right above its box. A ghost goes
// right under what it is the rest of.
const ghost = this.ghostSprites.get(id);
if (ghost) this.objects.addChild(this.clipTo(ghost, clip, true));
const sprite = this.sprites.get(id);
if (sprite) this.objects.addChild(this.clipTo(sprite, clip));
const node = this.nodes.get(id);
if (node) this.objects.addChild(node);
const ghostText = this.ghostTexts.get(id);
if (ghostText) this.objects.addChild(this.clipTo(ghostText.mesh, clip, true));
const text = this.texts.get(id);
if (text) this.objects.addChild(this.clipTo(text.mesh, clip));
const field = this.fields.get(id);
if (field) this.objects.addChild(this.clipTo(field.container, clip));
}
}
/**
* Cuts `target` to the region, or with `inverse` to everything outside it. The mask lives in
* world units like the objects, and stays with the target until the target goes.
*/
private clipTo<T extends Container>(target: T, region: ClipRegion | null, inverse = false): T {
let mask = this.maskOf.get(target);
if (!region) {
if (mask) {
target.mask = null;
mask.destroy();
this.maskOf.delete(target);
}
return target;
}
if (!mask) {
mask = new Graphics();
this.masks.addChild(mask);
this.maskOf.set(target, mask);
target.setMask({ mask, inverse });
}
mask.clear();
if (region.length >= 3) mask.poly([...region]).fill(0xffffff);
return target;
}
/** Masks whose target is gone: a tilemap drops the containers of layers it no longer has itself. */
private sweepMasks(): void {
for (const [target, mask] of this.maskOf)
if (target.destroyed) {
mask.destroy();
this.maskOf.delete(target);
}
}
private dropGhosts(id: string): void {
const sprite = this.ghostSprites.get(id);
if (sprite) disposeSpriteMesh(sprite);
this.ghostSprites.delete(id);
this.ghostTexts.get(id)?.destroy();
this.ghostTexts.delete(id);
}
/** Whether the item has a cut-off part to show faintly right now. */
private ghosted(item: DrawItem, onScreen: boolean): boolean {
return onScreen && item.clip !== null && this.opensClip(item);
}
/**
* The particles of one source. Their positions are world units, like everything else under
* `objects`, so the container itself carries no transform: the source's own goes into each
* particle, which is what lets one stay where it was born while its source flies on.
*/
private syncParticles(item: DrawItem, onScreen: boolean, shaders: ShaderSource): void {
let field = this.fields.get(item.id);
if (item.type !== 'particles' || !item.particles || !onScreen) {
if (field) field.container.visible = false;
return;
}
if (!field) {
field = new ParticleField();
this.fields.set(item.id, field);
this.objects.addChild(field.container);
}
field.container.visible = true;
// A source is a moving picture by definition, so the view keeps redrawing while one is on screen.
this.animated = true;
const build = particleLibrary.buildFor(item.particles);
const compiled = build.compiled;
const params = compiled ? compiled.params.map((row) => Number(item.props[row.key] ?? row.default)) : [];
const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE;
const texture = item.texture ? textureLibrary.get(item.texture, edge) : null;
field.wear(item.props, shaders, texture);
field.sync(
compiled,
item.props,
params,
item.m,
texture ?? Texture.WHITE,
particleLibrary.now(),
item.emits,
hashId(item.id),
);
}
/**
* A painted map, drawn from its own meshes. It keeps redrawing the view while an animated
* tile is on it, the same way a Time shader does.
*/
private syncTilemap(item: DrawItem, onScreen: boolean): void {
let field = this.tilemaps.get(item.id);
if (item.type !== 'tilemap' || !item.tilemap || !onScreen) {
if (field) field.visible = false;
return;
}
if (!field) {
// Its layers are attached by `syncDrawOrder`, which runs after every item is synced.
field = new TilemapField();
this.tilemaps.set(item.id, field);
}
field.visible = true;
field.sync(item, textureLibrary, gameClock.now, this.tilemapFocus(item));
if (field.animated) this.animated = true;
}
/**
* Which layer of a tilemap is singled out and how (#138). The game view shows every map as the
* game does; only the scene view, where one paints, has a say.
*/
protected tilemapFocus(_item: DrawItem): LayerFocus | null {
return null;
}
/** Where the centre of one cell of a placed tilemap is on screen, so a check can sample it. */
cellScreen(objectId: string, x: number, y: number): Vec2 | null {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!view || !item || item.type !== 'tilemap') return null;
const centre = cellCentre(item, x, y);
const local = worldToScreen(view, centre.x, centre.y);
const origin = this.getGlobalPosition();
return { x: origin.x + local.x, y: origin.y + local.y };
}
/**
* What a placed tilemap drew last redraw: how many meshes, and how many cells in them; `builds`
* counts the geometries built since the map came on screen (test hook).
*/
tilemapInfo(objectId: string): { drawCalls: number; builds: number; tiles: number; layers: number } | null {
const field = this.tilemaps.get(objectId);
const item = this.lastItems.find((i) => i.id === objectId);
if (!field?.visible || !item?.tilemap) return null;
return {
drawCalls: field.drawCalls,
builds: field.builds,
tiles: item.tilemap.layers.reduce((sum, layer) => sum + layer.tiles.length, 0),
layers: item.tilemap.layers.length,
};
}
private syncSprite(item: DrawItem, onScreen: boolean, size: Size, shaders: ShaderSource): void {
let sprite = this.sprites.get(item.id);
if (item.type !== 'sprite' || !onScreen) {
if (sprite) sprite.visible = false;
const ghost = this.ghostSprites.get(item.id);
if (ghost) disposeSpriteMesh(ghost);
this.ghostSprites.delete(item.id);
return;
}
if (!sprite) {
sprite = newSpriteMesh();
this.sprites.set(item.id, sprite);
this.objects.addChild(sprite);
}
sprite.visible = true;
if (wornShader(item.props, shaders)?.time) this.animated = true;
syncSpriteMesh(sprite, item, size, textureLibrary, shaders);
let ghost = this.ghostSprites.get(item.id);
if (!this.ghosted(item, onScreen)) {
if (ghost) disposeSpriteMesh(ghost);
this.ghostSprites.delete(item.id);
return;
}
if (!ghost) {
ghost = newSpriteMesh();
ghost.alpha = CLIP_GHOST_ALPHA;
this.ghostSprites.set(item.id, ghost);
}
syncSpriteMesh(ghost, item, size, textureLibrary, shaders);
}
/**
* The typeface a text is drawn in and how wide that makes it. Both are what a font asset is
* for, and both arrive asynchronously, so a test cannot read them off the model.
*/
textInfo(id: string): ReturnType<TextPicture['info']> | null {
const text = this.texts.get(id);
return text?.mesh.visible ? text.info() : null;
}
private syncText(item: DrawItem, onScreen: boolean, scale: number, shaders: ShaderSource): void {
let text = this.texts.get(item.id);
if (item.type !== 'text' || !onScreen) {
if (text) text.mesh.visible = false;
this.ghostTexts.get(item.id)?.destroy();
this.ghostTexts.delete(item.id);
return;
}
if (!text) {
text = new TextPicture();
this.texts.set(item.id, text);
this.objects.addChild(text.mesh);
}
text.mesh.visible = true;
if (wornShader(item.props, shaders)?.time) this.animated = true;
text.sync(item, scale, shaders);
let ghost = this.ghostTexts.get(item.id);
if (!this.ghosted(item, onScreen)) {
ghost?.destroy();
this.ghostTexts.delete(item.id);
return;
}
if (!ghost) {
ghost = new TextPicture();
ghost.mesh.alpha = CLIP_GHOST_ALPHA;
this.ghostTexts.set(item.id, ghost);
}
ghost.sync(item, scale, shaders);
}
}