minecraft-renderer 0.1.74 → 0.1.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-renderer",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -8,6 +8,15 @@ import { createCanvas, loadImageFromUrl } from '../utils'
8
8
  export const stevePngUrl = stevePng
9
9
  export const steveTexture = loadThreeJsTextureFromUrl(stevePngUrl)
10
10
 
11
+ const SKIN_TEXTURE_WIDTHS = [64, 128, 256, 512, 1024] as const
12
+
13
+ /** Minecraft skin sheets: WxW or Wx(W/2) at standard power-of-two widths. */
14
+ export const isLikelySkinImageSize = (width: number, height: number) => {
15
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return false
16
+ if (!(SKIN_TEXTURE_WIDTHS as readonly number[]).includes(width)) return false
17
+ return height === width || height === width / 2
18
+ }
19
+
11
20
  const config = {
12
21
  apiEnabled: true,
13
22
  }
@@ -1,5 +1,4 @@
1
1
  //@ts-nocheck
2
- import PrismarineChatLoader from 'prismarine-chat'
3
2
  import * as THREE from 'three'
4
3
  import * as nbt from 'prismarine-nbt'
5
4
  import { Vec3 } from 'vec3'
@@ -20,13 +19,12 @@ import {
20
19
  sectionAabbIntersectsRay,
21
20
  type ShaderSectionRaycastEntry,
22
21
  } from './sectionRaycastAabb'
23
- import { chunkPos } from '../lib/simpleUtils'
24
- import { renderSign } from '../sign-renderer'
25
22
  import { getMesh } from './entity/EntityMesh'
26
23
  import type { WorldRendererThree } from './worldRendererThree'
27
24
  import { armorModel } from './entity/armorModels'
28
25
  import { disposeObject } from './threeJsUtils'
29
26
  import { getBannerTexture, createBannerMesh, releaseBannerTexture } from './bannerRenderer'
27
+ import { getSignTexture, releaseSignTexture, disposeAllSignTextures } from './signTextureCache'
30
28
  import { BlockEntityLightRegistry } from '../lib/blockEntityLightRegistry'
31
29
 
32
30
  export interface ChunkMeshPool {
@@ -74,6 +72,21 @@ export interface SectionObject extends THREE.Group {
74
72
  _waitingForChunkDisplay?: boolean
75
73
  }
76
74
 
75
+ /** Live vs allocated stats for one global GPU buffer (faces or legacy quads). */
76
+ export type GlobalBufferSlotStats = {
77
+ used: number
78
+ capacity: number
79
+ sections: number
80
+ usedBytes: number
81
+ capacityBytes: number
82
+ }
83
+
84
+ export type GlobalBufferStats = {
85
+ shaderFaces: GlobalBufferSlotStats | null
86
+ legacyOpaque: GlobalBufferSlotStats | null
87
+ legacyBlend: GlobalBufferSlotStats | null
88
+ }
89
+
77
90
  export class ChunkMeshManager {
78
91
  private static readonly REBASE_THRESHOLD = 65536
79
92
 
@@ -1348,8 +1361,12 @@ export class ChunkMeshManager {
1348
1361
  sectionObject.shaderMesh = undefined
1349
1362
  }
1350
1363
  delete sectionObject.deferredShaderCubes
1351
- // Dispose signs and heads containers
1364
+ // Release shared sign textures (refcount) before disposing meshes
1352
1365
  if (sectionObject.signsContainer) {
1366
+ for (const child of sectionObject.signsContainer.children) {
1367
+ const sign = child as THREE.Group & { signTexture?: THREE.Texture }
1368
+ if (sign.signTexture) releaseSignTexture(sign.signTexture)
1369
+ }
1353
1370
  this.disposeContainer(sectionObject.signsContainer, false)
1354
1371
  }
1355
1372
  if (sectionObject.headsContainer) {
@@ -1473,16 +1490,6 @@ export class ChunkMeshManager {
1473
1490
  }
1474
1491
  }
1475
1492
 
1476
- /**
1477
- * Forward to {@link SignHeadsRenderer.cleanChunkTextures} so callers in
1478
- * `WorldRendererThree` (which historically owned the sign-texture cache)
1479
- * can invalidate cached sign textures when a section is marked dirty,
1480
- * without reaching into the manager's private members.
1481
- */
1482
- cleanSignChunkTextures (x: number, z: number) {
1483
- this.signHeadsRenderer.cleanChunkTextures(x, z)
1484
- }
1485
-
1486
1493
  /**
1487
1494
  * Get mesh for section if it exists
1488
1495
  */
@@ -1521,6 +1528,34 @@ export class ChunkMeshManager {
1521
1528
  /**
1522
1529
  * Get pool statistics
1523
1530
  */
1531
+ getGlobalBufferStats (): GlobalBufferStats {
1532
+ const snapshotLegacy = (buffer: GlobalLegacyBuffer | null): GlobalBufferSlotStats | null => {
1533
+ if (!buffer) return null
1534
+ return {
1535
+ used: buffer.getHighWatermark(),
1536
+ capacity: buffer.getCapacityQuads(),
1537
+ sections: buffer.getSectionCount(),
1538
+ usedBytes: buffer.getUsedMemoryBytes(),
1539
+ capacityBytes: buffer.getMemoryBytes(),
1540
+ }
1541
+ }
1542
+
1543
+ const cubes = this.globalBlockBuffer
1544
+ return {
1545
+ shaderFaces: cubes
1546
+ ? {
1547
+ used: cubes.getHighWatermark(),
1548
+ capacity: cubes.getCapacityFaces(),
1549
+ sections: cubes.getSectionCount(),
1550
+ usedBytes: cubes.getUsedMemoryBytes(),
1551
+ capacityBytes: cubes.getMemoryBytes(),
1552
+ }
1553
+ : null,
1554
+ legacyOpaque: snapshotLegacy(this.globalLegacyBuffer),
1555
+ legacyBlend: snapshotLegacy(this.globalLegacyBlendBuffer),
1556
+ }
1557
+ }
1558
+
1524
1559
  getStats () {
1525
1560
  const freeCount = this.meshPool.filter(entry => !entry.inUse).length
1526
1561
  const hitRate = this.hits + this.misses > 0 ? (this.hits / (this.hits + this.misses) * 100).toFixed(1) : '0'
@@ -1923,21 +1958,12 @@ export class ChunkMeshManager {
1923
1958
  }
1924
1959
 
1925
1960
 
1926
- type SignTextureCacheEntry = { tex: THREE.Texture, signature: string }
1927
-
1928
1961
  class SignHeadsRenderer {
1929
- chunkTextures = new Map<string, { [pos: string]: SignTextureCacheEntry }>()
1930
-
1931
1962
  constructor (public worldRendererThree: WorldRendererThree) {
1932
1963
  }
1933
1964
 
1934
1965
  dispose () {
1935
- for (const [, textures] of this.chunkTextures) {
1936
- for (const key of Object.keys(textures)) {
1937
- textures[key]!.tex.dispose()
1938
- }
1939
- }
1940
- this.chunkTextures.clear()
1966
+ disposeAllSignTextures()
1941
1967
  }
1942
1968
 
1943
1969
  renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
@@ -1981,18 +2007,10 @@ class SignHeadsRenderer {
1981
2007
  }
1982
2008
 
1983
2009
  renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) {
1984
- const tex = this.getSignTexture(position, blockEntity, isHanging)
2010
+ const tex = getSignTexture(this.worldRendererThree, blockEntity, isHanging)
1985
2011
 
1986
2012
  if (!tex) return
1987
2013
 
1988
- // todo implement
1989
- // const key = JSON.stringify({ position, rotation, isWall })
1990
- // if (this.signsCache.has(key)) {
1991
- // console.log('cached', key)
1992
- // } else {
1993
- // this.signsCache.set(key, tex)
1994
- // }
1995
-
1996
2014
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: tex, transparent: true }))
1997
2015
  mesh.renderOrder = 999
1998
2016
 
@@ -2008,13 +2026,14 @@ class SignHeadsRenderer {
2008
2026
  mesh.position.set(0, 0, thickness / 2 + 0.0001)
2009
2027
  }
2010
2028
 
2011
- const group = new THREE.Group()
2029
+ const group = new THREE.Group() as THREE.Group & { signTexture?: THREE.Texture }
2012
2030
  group.rotation.set(
2013
2031
  0,
2014
2032
  -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)),
2015
2033
  0
2016
2034
  )
2017
2035
  group.add(mesh)
2036
+ group.signTexture = tex
2018
2037
  const height = (isHanging ? 10 : 8) / 16
2019
2038
  const heightOffset = (isHanging ? 0 : isWall ? 4.333 : 9.333) / 16
2020
2039
  const textPosition = height / 2 + heightOffset
@@ -2022,47 +2041,4 @@ class SignHeadsRenderer {
2022
2041
  group.position.set(position.x + 0.5, position.y + textPosition, position.z + 0.5)
2023
2042
  return group
2024
2043
  }
2025
-
2026
- getSignTexture (position: Vec3, blockEntity, isHanging, backSide = false) {
2027
- const chunk = chunkPos(position)
2028
- let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`)
2029
- if (!textures) {
2030
- textures = {}
2031
- this.chunkTextures.set(`${chunk[0]},${chunk[1]}`, textures)
2032
- }
2033
- const texturekey = `${position.x},${position.y},${position.z}`
2034
- const signature = JSON.stringify(blockEntity) + '|' + isHanging + '|' + backSide
2035
- const cached = textures[texturekey]
2036
- if (cached && cached.signature === signature) return cached.tex
2037
-
2038
- if (cached?.tex) cached.tex.dispose()
2039
-
2040
- const PrismarineChat = PrismarineChatLoader(this.worldRendererThree.version)
2041
- const canvas = renderSign(blockEntity, isHanging, PrismarineChat)
2042
- if (!canvas) return
2043
- const tex = new THREE.Texture(canvas)
2044
- tex.magFilter = THREE.NearestFilter
2045
- tex.minFilter = THREE.NearestFilter
2046
- tex.needsUpdate = true
2047
- textures[texturekey] = { tex, signature }
2048
- return tex
2049
- }
2050
-
2051
- /**
2052
- * Dispose all cached sign textures for the chunk containing world coords
2053
- * (x, z). Called from `WorldRendererThree.cleanChunkTextures` so that
2054
- * re-meshes triggered by `setSectionDirty` (e.g. a player edits a sign)
2055
- * pick up fresh block-entity NBT instead of returning the stale cached
2056
- * texture from {@link SignHeadsRenderer.getSignTexture}.
2057
- */
2058
- cleanChunkTextures (x: number, z: number) {
2059
- const key = `${Math.floor(x / 16)},${Math.floor(z / 16)}`
2060
- const textures = this.chunkTextures.get(key)
2061
- if (!textures) return
2062
- const disposedKeys = Object.keys(textures)
2063
- for (const k of disposedKeys) {
2064
- textures[k]!.tex.dispose()
2065
- delete textures[k]
2066
- }
2067
- }
2068
2044
  }
@@ -635,6 +635,17 @@ export class Entities {
635
635
  }
636
636
  }
637
637
 
638
+ /** Local preview override: hand + player model until server skin refresh or reconnect. */
639
+ async applyTemporaryPlayerSkinOverride (
640
+ skinUrl: string,
641
+ entityId: string | number,
642
+ username?: string,
643
+ uuid?: string
644
+ ) {
645
+ this.worldRenderer.playerStateReactive.playerSkin = skinUrl
646
+ await this.updatePlayerSkin(entityId, username, uuid, skinUrl, undefined)
647
+ }
648
+
638
649
  private async loadAndApplySkin(entityId: string | number, skinUrl: string, renderEars: boolean) {
639
650
  let playerObject = this.getPlayerObject(entityId)
640
651
  if (!playerObject) return
@@ -35,6 +35,9 @@ const MAX_UPLOAD_FACES_PER_FRAME = 15_000 // face-indexed budget (chunksStorag
35
35
  const FRAGMENTATION_THRESHOLD = 0.25
36
36
  const EMPTY_W2 = packWord2Empty()
37
37
 
38
+ /** CPU bytes per instanced cube face (a_w0..a_w3). */
39
+ export const SHADER_CUBE_BYTES_PER_FACE = 16
40
+
38
41
  type PendingMove = { key: string, oldStart: number, newStart: number, count: number }
39
42
 
40
43
  export type GlobalBlockBufferShaderData = {
@@ -162,6 +165,22 @@ export class GlobalBlockBuffer {
162
165
  return this.highWatermark
163
166
  }
164
167
 
168
+ getCapacityFaces (): number {
169
+ return this.capacityFaces
170
+ }
171
+
172
+ getSectionCount (): number {
173
+ return this.sectionSlots.size
174
+ }
175
+
176
+ getMemoryBytes (): number {
177
+ return this.capacityFaces * SHADER_CUBE_BYTES_PER_FACE
178
+ }
179
+
180
+ getUsedMemoryBytes (): number {
181
+ return this.highWatermark * SHADER_CUBE_BYTES_PER_FACE
182
+ }
183
+
165
184
  hasPendingUploads (): boolean {
166
185
  return this.pendingRanges.length > 0
167
186
  }
@@ -12,6 +12,11 @@ const DEFAULT_INITIAL_CAPACITY_QUADS = 128_000
12
12
  const DEFAULT_GROWTH_INCREMENT_QUADS = 128_000
13
13
  const MAX_UPLOAD_QUADS_PER_FRAME = 5_000
14
14
 
15
+ /** CPU bytes per allocated quad slot (all legacy vertex/index attrs). */
16
+ export const LEGACY_BYTES_PER_QUAD =
17
+ VERTS_PER_QUAD * (FLOATS_PER_VERT * 3 + FLOATS_PER_LIGHT_VERT * 2 + FLOATS_PER_UV_VERT) * 4
18
+ + INDICES_PER_QUAD * 4
19
+
15
20
  export const FULL_DRAW_VISIBLE_FRACTION = 0.75
16
21
  export const SPAN_GAP_TOLERANCE_QUADS = 256
17
22
  export const MAX_OPAQUE_SPANS = 64
@@ -475,10 +480,24 @@ export class GlobalLegacyBuffer {
475
480
  return out
476
481
  }
477
482
 
483
+ getHighWatermark (): number {
484
+ return this.highWatermark
485
+ }
486
+
487
+ getCapacityQuads (): number {
488
+ return this.capacityQuads
489
+ }
490
+
491
+ getSectionCount (): number {
492
+ return this.sectionSlots.size
493
+ }
494
+
478
495
  getMemoryBytes (): number {
479
- const verts = this.capacityQuads * VERTS_PER_QUAD
480
- return verts * (FLOATS_PER_VERT * 3 + FLOATS_PER_LIGHT_VERT * 2 + FLOATS_PER_UV_VERT) * 4
481
- + this.capacityQuads * INDICES_PER_QUAD * 4
496
+ return this.capacityQuads * LEGACY_BYTES_PER_QUAD
497
+ }
498
+
499
+ getUsedMemoryBytes (): number {
500
+ return this.highWatermark * LEGACY_BYTES_PER_QUAD
482
501
  }
483
502
 
484
503
  reset (): void {
@@ -35,6 +35,7 @@ export const getBackendMethods = (worldRenderer: WorldRendererThree): any => {
35
35
  playEntityAnimation: worldRenderer.entities.playAnimation.bind(worldRenderer.entities),
36
36
  damageEntity: worldRenderer.entities.handleDamageEvent.bind(worldRenderer.entities),
37
37
  updatePlayerSkin: worldRenderer.entities.updatePlayerSkin.bind(worldRenderer.entities),
38
+ applyTemporaryPlayerSkinOverride: worldRenderer.entities.applyTemporaryPlayerSkinOverride.bind(worldRenderer.entities),
38
39
  changeHandSwingingState: worldRenderer.changeHandSwingingState.bind(worldRenderer),
39
40
  getHighestBlocks: worldRenderer.getHighestBlocks.bind(worldRenderer),
40
41
  reloadWorld: worldRenderer.reloadWorld.bind(worldRenderer),
@@ -19,7 +19,7 @@ const createGraphicsBackendSingleThread: GraphicsBackendLoader = (initOptions: G
19
19
  }
20
20
 
21
21
  createGraphicsBackendSingleThread.id = 'threejs'
22
- createGraphicsBackendSingleThread.displayName = 'three.js Blocking Beta'
22
+ createGraphicsBackendSingleThread.displayName = 'three.js Blocking'
23
23
  createGraphicsBackendSingleThread.description = 'Simple, old and stable main thread graphics backend providing balanced performance on top of WebGL2.'
24
24
 
25
25
  export default createGraphicsBackendSingleThread
@@ -0,0 +1,64 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+ import PrismarineChatLoader from 'prismarine-chat'
4
+ import { renderSign } from '../sign-renderer'
5
+ import type { WorldRendererThree } from './worldRendererThree'
6
+
7
+ const signTextureCache = new Map<string, { texture: THREE.Texture, refCount: number }>()
8
+
9
+ // Build the key ONLY from fields that change rendered pixels, so two signs
10
+ // with identical visible text share a texture even if other NBT differs.
11
+ function createSignCacheKey (blockEntity: any, isHanging: boolean, backSide: boolean): string {
12
+ let lines: string[]
13
+ let color: string
14
+ if (blockEntity && 'front_text' in blockEntity) { // 1.20+
15
+ lines = blockEntity.front_text?.messages ?? []
16
+ color = blockEntity.front_text?.color || 'black'
17
+ } else { // legacy
18
+ lines = [blockEntity?.Text1, blockEntity?.Text2, blockEntity?.Text3, blockEntity?.Text4]
19
+ color = blockEntity?.Color || 'black'
20
+ }
21
+ // \0 separator: cannot appear in JSON text components, so no key collisions
22
+ return `${isHanging ? 1 : 0}|${backSide ? 1 : 0}|${color}|${lines.join('\0')}`
23
+ }
24
+
25
+ export function getSignTexture (
26
+ worldRenderer: WorldRendererThree,
27
+ blockEntity: any,
28
+ isHanging: boolean,
29
+ backSide = false
30
+ ): THREE.Texture | undefined {
31
+ const cacheKey = createSignCacheKey(blockEntity, isHanging, backSide)
32
+ const cached = signTextureCache.get(cacheKey)
33
+ if (cached) {
34
+ cached.refCount++
35
+ return cached.texture
36
+ }
37
+ const PrismarineChat = PrismarineChatLoader(worldRenderer.version)
38
+ const canvas = renderSign(blockEntity, isHanging, PrismarineChat)
39
+ if (!canvas) return undefined
40
+ const tex = new THREE.Texture(canvas)
41
+ tex.magFilter = THREE.NearestFilter
42
+ tex.minFilter = THREE.NearestFilter
43
+ tex.needsUpdate = true
44
+ signTextureCache.set(cacheKey, { texture: tex, refCount: 1 })
45
+ return tex
46
+ }
47
+
48
+ export function releaseSignTexture (texture: THREE.Texture): void {
49
+ for (const [key, cached] of signTextureCache.entries()) {
50
+ if (cached.texture === texture) {
51
+ cached.refCount--
52
+ if (cached.refCount <= 0) {
53
+ cached.texture.dispose()
54
+ signTextureCache.delete(key)
55
+ }
56
+ return
57
+ }
58
+ }
59
+ }
60
+
61
+ export function disposeAllSignTextures (): void {
62
+ for (const [, cached] of signTextureCache) cached.texture.dispose()
63
+ signTextureCache.clear()
64
+ }
@@ -1,11 +1,6 @@
1
1
  //@ts-nocheck
2
2
  import { test, expect, vi, beforeEach } from 'vitest'
3
3
  import * as THREE from 'three'
4
- import { Vec3 } from 'vec3'
5
-
6
- vi.mock('../entity/EntityMesh', () => ({
7
- getMesh: vi.fn(),
8
- }))
9
4
 
10
5
  const renderSignMock = vi.fn()
11
6
  vi.mock('../../sign-renderer', () => ({
@@ -16,25 +11,11 @@ vi.mock('prismarine-chat', () => ({
16
11
  default: () => () => ({}),
17
12
  }))
18
13
 
19
- import { ChunkMeshManager } from '../chunkMeshManager'
14
+ import { getSignTexture, releaseSignTexture, disposeAllSignTextures } from '../signTextureCache'
20
15
  import type { WorldRendererThree } from '../worldRendererThree'
21
16
 
22
- function createManager (): ChunkMeshManager {
23
- const scene = new THREE.Scene()
24
- const material = new THREE.MeshBasicMaterial()
25
- const worldRenderer = {
26
- version: '1.20',
27
- shaderCubeBlocksEnabled: () => false,
28
- getModule: () => undefined,
29
- sceneOrigin: {
30
- track: () => {},
31
- removeAndUntrack: () => {},
32
- removeAndUntrackAll: () => {},
33
- },
34
- blockEntities: {},
35
- worldRendererConfig: {},
36
- } as unknown as WorldRendererThree
37
- return new ChunkMeshManager(worldRenderer, scene, material, 256, 1)
17
+ function createWorldRenderer (): WorldRendererThree {
18
+ return { version: '1.20' } as WorldRendererThree
38
19
  }
39
20
 
40
21
  function stubCanvas () {
@@ -44,40 +25,80 @@ function stubCanvas () {
44
25
  beforeEach(() => {
45
26
  renderSignMock.mockReset()
46
27
  renderSignMock.mockImplementation(() => stubCanvas())
28
+ disposeAllSignTextures()
47
29
  })
48
30
 
49
- test('getSignTexture: same blockEntity returns cached texture without re-render', () => {
50
- const manager = createManager()
51
- const signHeadsRenderer = (manager as unknown as { signHeadsRenderer: { getSignTexture: Function } }).signHeadsRenderer
52
- const pos = new Vec3(10, 64, 10)
31
+ test('getSignTexture: same content at different positions shares one texture', () => {
32
+ const wr = createWorldRenderer()
53
33
  const blockEntity = { Text1: '{"text":"Hello"}' }
54
34
 
55
- const tex1 = signHeadsRenderer.getSignTexture(pos, blockEntity, false)
56
- const tex2 = signHeadsRenderer.getSignTexture(pos, blockEntity, false)
35
+ const tex1 = getSignTexture(wr, blockEntity, false)
36
+ const tex2 = getSignTexture(wr, { ...blockEntity }, false)
57
37
 
58
38
  expect(tex1).toBeDefined()
59
39
  expect(tex2).toBe(tex1)
60
40
  expect(renderSignMock).toHaveBeenCalledTimes(1)
41
+ })
42
+
43
+ test('getSignTexture: different text yields different textures', () => {
44
+ const wr = createWorldRenderer()
61
45
 
62
- manager.dispose()
46
+ const tex1 = getSignTexture(wr, { Text1: '{"text":"Hello"}' }, false)!
47
+ const tex2 = getSignTexture(wr, { Text1: '{"text":"World"}' }, false)!
48
+
49
+ expect(tex2).not.toBe(tex1)
50
+ expect(renderSignMock).toHaveBeenCalledTimes(2)
63
51
  })
64
52
 
65
- test('getSignTexture: changed blockEntity disposes old texture and renders anew', () => {
66
- const manager = createManager()
67
- const signHeadsRenderer = (manager as unknown as { signHeadsRenderer: { getSignTexture: Function } }).signHeadsRenderer
68
- const pos = new Vec3(10, 64, 10)
53
+ test('releaseSignTexture: partial release keeps texture in cache', () => {
54
+ const wr = createWorldRenderer()
69
55
  const blockEntity = { Text1: '{"text":"Hello"}' }
70
56
 
71
- const tex1 = signHeadsRenderer.getSignTexture(pos, blockEntity, false)!
57
+ const tex1 = getSignTexture(wr, blockEntity, false)!
58
+ const tex2 = getSignTexture(wr, blockEntity, false)!
59
+ expect(tex1).toBe(tex2)
60
+
72
61
  const disposeSpy = vi.spyOn(tex1, 'dispose')
62
+ releaseSignTexture(tex1)
63
+
64
+ expect(disposeSpy).not.toHaveBeenCalled()
65
+ expect(getSignTexture(wr, blockEntity, false)).toBe(tex1)
66
+ expect(renderSignMock).toHaveBeenCalledTimes(1)
67
+ })
73
68
 
74
- const changed = { Text1: '{"text":"World"}' }
75
- const tex2 = signHeadsRenderer.getSignTexture(pos, changed, false)
69
+ test('releaseSignTexture: dispose at zero refcount removes cache entry', () => {
70
+ const wr = createWorldRenderer()
71
+ const blockEntity = { Text1: '{"text":"Hello"}' }
76
72
 
77
- expect(tex2).toBeDefined()
78
- expect(tex2).not.toBe(tex1)
73
+ const tex = getSignTexture(wr, blockEntity, false)!
74
+ const disposeSpy = vi.spyOn(tex, 'dispose')
75
+
76
+ releaseSignTexture(tex)
79
77
  expect(disposeSpy).toHaveBeenCalledTimes(1)
78
+
79
+ const tex2 = getSignTexture(wr, blockEntity, false)!
80
+ expect(tex2).not.toBe(tex)
80
81
  expect(renderSignMock).toHaveBeenCalledTimes(2)
82
+ })
83
+
84
+ test('getSignTexture: key ignores irrelevant NBT fields', () => {
85
+ const wr = createWorldRenderer()
86
+ const base = { Text1: '{"text":"Hello"}', Color: 'black' }
81
87
 
82
- manager.dispose()
88
+ const tex1 = getSignTexture(wr, base, false)!
89
+ const tex2 = getSignTexture(wr, { ...base, GlowingText: 1, is_waxed: 1 }, false)!
90
+
91
+ expect(tex2).toBe(tex1)
92
+ expect(renderSignMock).toHaveBeenCalledTimes(1)
93
+ })
94
+
95
+ test('getSignTexture: isHanging is part of cache key', () => {
96
+ const wr = createWorldRenderer()
97
+ const blockEntity = { Text1: '{"text":"Hello"}' }
98
+
99
+ const standing = getSignTexture(wr, blockEntity, false)!
100
+ const hanging = getSignTexture(wr, blockEntity, true)!
101
+
102
+ expect(hanging).not.toBe(standing)
103
+ expect(renderSignMock).toHaveBeenCalledTimes(2)
83
104
  })