minecraft-renderer 0.1.73 → 0.1.75

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.73",
3
+ "version": "0.1.75",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -81,6 +81,10 @@ export const defaultWorldRendererConfig = {
81
81
  isPlayground: false,
82
82
  instantCameraUpdate: false,
83
83
  isRaining: false,
84
+ // rainColor: 'rgb(64, 87, 148)', // original minecraft blue
85
+ rainColor: 'rgb(118, 148, 226)',
86
+ /** Rain particle opacity 0–1. */
87
+ rainOpacity: 0.5,
84
88
 
85
89
  // Module states: 'enabled' = force on, 'disabled' = force off, 'auto' = use autoEnableCheck
86
90
  moduleStates: {} as Record<string, 'enabled' | 'disabled' | 'auto'>
@@ -101,6 +101,7 @@ export interface GraphicsInitOptions<S = any> {
101
101
  /** Live app options (e.g. valtio proxy); used for WebGL `gpuPreference` at context creation. */
102
102
  getRendererOptions?: () => RendererStorageOptions
103
103
  rendererSpecificSettings: S
104
+ hello?: boolean
104
105
  callbacks: {
105
106
  displayCriticalError: (error: Error) => void
106
107
  setRendererSpecificSettings: (key: string, value: any) => void
@@ -10,7 +10,7 @@ import type { WorldViewWorker } from '../worldView'
10
10
  export function bindAbortableListener<E extends keyof WorldViewEvents>(
11
11
  emitter: Pick<WorldViewWorker, 'on' | 'off'>,
12
12
  event: E,
13
- handler: (...args: WorldViewEvents[E]) => void,
13
+ handler: (...args: Parameters<WorldViewEvents[E]>) => void,
14
14
  signal: AbortSignal
15
15
  ): void {
16
16
  emitter.on(event, handler as (...args: any[]) => void)
@@ -12,7 +12,7 @@ export type PlayerObjectType = PlayerObject & {
12
12
 
13
13
  /** Starfield + log-depth world: cutout skin mats need alphaTest and depthWrite (not mesh traverse). */
14
14
  export function configurePlayerSkinMaterials (playerObject: PlayerObject): void {
15
- const skin = playerObject.skin
15
+ const skin = playerObject.skin as any
16
16
  const materials = [
17
17
  skin.layer1Material,
18
18
  skin.layer1MaterialBiased,
@@ -6,6 +6,7 @@ import { proxy } from 'valtio'
6
6
  import * as worldRendererModule from './worldrendererCommon'
7
7
  import { WorldRendererCommon } from './worldrendererCommon'
8
8
  import { defaultWorldRendererConfig } from '../graphicsBackend/config'
9
+ import { defaultPerformanceInstabilityFactors } from '../performanceMonitor'
9
10
  import { getInitialPlayerState } from '../playerState/playerState'
10
11
  import type { DisplayWorldOptions, GraphicsInitOptions } from '../graphicsBackend/types'
11
12
 
@@ -46,6 +47,8 @@ class TestWorldRenderer extends WorldRendererCommon {
46
47
  updateCamera() {}
47
48
  render() {}
48
49
  updateShowChunksBorder() {}
50
+ updatePlayerEntity() {}
51
+ worldStop() {}
49
52
  }
50
53
 
51
54
  function createRenderer(workerCount = 2, worldView?: DisplayWorldOptions['worldView']) {
@@ -56,7 +59,7 @@ function createRenderer(workerCount = 2, worldView?: DisplayWorldOptions['worldV
56
59
  heightmaps: {} as Record<string, Int16Array>,
57
60
  allChunksLoaded: false,
58
61
  mesherWork: false,
59
- instabilityFactors: {},
62
+ instabilityFactors: defaultPerformanceInstabilityFactors(),
60
63
  intersectMedia: null,
61
64
  },
62
65
  renderer: '',
@@ -5,6 +5,7 @@ import { Vec3 } from 'vec3'
5
5
  import { proxy } from 'valtio'
6
6
  import { WorldRendererCommon } from './worldrendererCommon'
7
7
  import { defaultWorldRendererConfig } from '../graphicsBackend/config'
8
+ import { defaultPerformanceInstabilityFactors } from '../performanceMonitor'
8
9
  import { getInitialPlayerState } from '../playerState/playerState'
9
10
  import type { DisplayWorldOptions, GraphicsInitOptions } from '../graphicsBackend/types'
10
11
 
@@ -45,16 +46,18 @@ class TestWorldRenderer extends WorldRendererCommon {
45
46
  updateCamera() {}
46
47
  render() {}
47
48
  updateShowChunksBorder() {}
49
+ updatePlayerEntity() {}
50
+ worldStop() {}
48
51
  }
49
52
 
50
53
  function createRenderer() {
51
54
  const rendererState = proxy({
52
55
  world: {
53
- chunksLoaded: new Set<string>(),
54
- heightmaps: new Map<string, Int16Array>(),
56
+ chunksLoaded: {} as Record<string, true>,
57
+ heightmaps: {} as Record<string, Int16Array>,
55
58
  allChunksLoaded: false,
56
59
  mesherWork: false,
57
- instabilityFactors: {},
60
+ instabilityFactors: defaultPerformanceInstabilityFactors(),
58
61
  intersectMedia: null,
59
62
  },
60
63
  renderer: '',
@@ -73,6 +76,7 @@ function createRenderer() {
73
76
  avgRenderTime: 0,
74
77
  world: {
75
78
  chunksLoaded: new Set<string>(),
79
+ chunksLoadedCount: 0,
76
80
  chunksTotalNumber: 0,
77
81
  chunksFullInfo: '',
78
82
  },
@@ -93,7 +97,7 @@ function createRenderer() {
93
97
  },
94
98
  }
95
99
 
96
- const renderer = new TestWorldRenderer(displayOptions.resourcesManager, displayOptions, initOptions)
100
+ const renderer = new TestWorldRenderer(displayOptions.resourcesManager, displayOptions as DisplayWorldOptions, initOptions)
97
101
  renderer.active = true
98
102
  renderer.workers = [{ postMessage: vi.fn() }, { postMessage: vi.fn() }]
99
103
  renderer.viewDistance = 16
@@ -1443,7 +1443,7 @@ export const initMesherWorker = (onGotMessage: (data: any) => void, workerName =
1443
1443
  let mesherMcDataTintsMissingWarned = false
1444
1444
 
1445
1445
  export const meshersSendMcData = (workers: Worker[], version: string, mcDataKeys = dynamicMcDataFiles, mcDataFull: IndexedData) => {
1446
- const mcData = {
1446
+ const mcData: { version: IndexedData['version']; tints?: unknown; [key: string]: unknown } = {
1447
1447
  version: JSON.parse(JSON.stringify(mcDataFull.version))
1448
1448
  }
1449
1449
  for (const [finalKey, sourceKey] of Object.entries(mcDataKeys)) {
@@ -270,7 +270,10 @@ export function createBannerMesh(
270
270
  mesh.position.set(clothXOffset, clothYOffset, clothZPosition + thickness / 2 + 0.004)
271
271
  }
272
272
 
273
- const group = new THREE.Group() as THREE.Group & { bannerTexture?: THREE.Texture }
273
+ const group = new THREE.Group() as THREE.Group & {
274
+ bannerTexture?: THREE.Texture
275
+ bannerMaterial?: THREE.MeshBasicMaterial
276
+ }
274
277
  group.rotation.set(
275
278
  0,
276
279
  -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)),
@@ -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 {
@@ -1348,8 +1346,12 @@ export class ChunkMeshManager {
1348
1346
  sectionObject.shaderMesh = undefined
1349
1347
  }
1350
1348
  delete sectionObject.deferredShaderCubes
1351
- // Dispose signs and heads containers
1349
+ // Release shared sign textures (refcount) before disposing meshes
1352
1350
  if (sectionObject.signsContainer) {
1351
+ for (const child of sectionObject.signsContainer.children) {
1352
+ const sign = child as THREE.Group & { signTexture?: THREE.Texture }
1353
+ if (sign.signTexture) releaseSignTexture(sign.signTexture)
1354
+ }
1353
1355
  this.disposeContainer(sectionObject.signsContainer, false)
1354
1356
  }
1355
1357
  if (sectionObject.headsContainer) {
@@ -1473,16 +1475,6 @@ export class ChunkMeshManager {
1473
1475
  }
1474
1476
  }
1475
1477
 
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
1478
  /**
1487
1479
  * Get mesh for section if it exists
1488
1480
  */
@@ -1923,21 +1915,12 @@ export class ChunkMeshManager {
1923
1915
  }
1924
1916
 
1925
1917
 
1926
- type SignTextureCacheEntry = { tex: THREE.Texture, signature: string }
1927
-
1928
1918
  class SignHeadsRenderer {
1929
- chunkTextures = new Map<string, { [pos: string]: SignTextureCacheEntry }>()
1930
-
1931
1919
  constructor (public worldRendererThree: WorldRendererThree) {
1932
1920
  }
1933
1921
 
1934
1922
  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()
1923
+ disposeAllSignTextures()
1941
1924
  }
1942
1925
 
1943
1926
  renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
@@ -1981,18 +1964,10 @@ class SignHeadsRenderer {
1981
1964
  }
1982
1965
 
1983
1966
  renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) {
1984
- const tex = this.getSignTexture(position, blockEntity, isHanging)
1967
+ const tex = getSignTexture(this.worldRendererThree, blockEntity, isHanging)
1985
1968
 
1986
1969
  if (!tex) return
1987
1970
 
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
1971
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: tex, transparent: true }))
1997
1972
  mesh.renderOrder = 999
1998
1973
 
@@ -2008,13 +1983,14 @@ class SignHeadsRenderer {
2008
1983
  mesh.position.set(0, 0, thickness / 2 + 0.0001)
2009
1984
  }
2010
1985
 
2011
- const group = new THREE.Group()
1986
+ const group = new THREE.Group() as THREE.Group & { signTexture?: THREE.Texture }
2012
1987
  group.rotation.set(
2013
1988
  0,
2014
1989
  -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)),
2015
1990
  0
2016
1991
  )
2017
1992
  group.add(mesh)
1993
+ group.signTexture = tex
2018
1994
  const height = (isHanging ? 10 : 8) / 16
2019
1995
  const heightOffset = (isHanging ? 0 : isWall ? 4.333 : 9.333) / 16
2020
1996
  const textPosition = height / 2 + heightOffset
@@ -2022,47 +1998,4 @@ class SignHeadsRenderer {
2022
1998
  group.position.set(position.x + 0.5, position.y + textPosition, position.z + 0.5)
2023
1999
  return group
2024
2000
  }
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
2001
  }
@@ -552,7 +552,8 @@ export class Entities {
552
552
  currentSkinUrls = {} as Record<string, string>
553
553
 
554
554
  private isCanvasBlank(canvas: HTMLCanvasElement | OffscreenCanvas): boolean {
555
- return !canvas.getContext('2d')
555
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null
556
+ return !ctx
556
557
  ?.getImageData(0, 0, canvas.width, canvas.height).data
557
558
  .some(channel => channel !== 0)
558
559
  }
@@ -69,7 +69,7 @@ export const getBackendMethods = (worldRenderer: WorldRendererThree): any => {
69
69
  // New method for updating skybox
70
70
  setSkyboxImage: worldRenderer.skyboxRenderer.setSkyboxImage.bind(worldRenderer.skyboxRenderer),
71
71
  // Rain methods
72
- setRain: (newState: boolean) => worldRenderer.toggleModule('rain', newState),
72
+ setRain: worldRenderer.setRain.bind(worldRenderer),
73
73
  spawnBlockBreakParticles(x: number, y: number, z: number, blockName: string, floorMap: number[], biomeName?: string) {
74
74
  const module = worldRenderer.getModule<import('./modules/blockBreakParticles').BlockBreakParticlesModule>('blockBreakParticles')
75
75
  module?.spawnBlockBreakParticles(x, y, z, blockName, floorMap, biomeName)
@@ -145,6 +145,10 @@ export const createGraphicsBackendBase = () => {
145
145
  let frameTimingCollector: FrameTimingCollector | null = null
146
146
 
147
147
  const init = (initOptionsArg: GraphicsInitOptions, mainData?: ThreeRendererMainData) => {
148
+ if (initOptionsArg.hello) {
149
+ console.log('Thanks for using minecraft-renderer project: one of the most performant Minecraft world renderers for the web!')
150
+ }
151
+
148
152
  if (isWebWorker) {
149
153
  initOptions = restoreTransferred(initOptionsArg, initOptionsRestorers, globalThis as unknown as Worker)
150
154
  } else {
@@ -166,8 +170,8 @@ export const createGraphicsBackendBase = () => {
166
170
  worldRenderer.destroy()
167
171
  worldRenderer = null
168
172
  frameTimingCollector = null
169
- ;(globalThis as any).world = undefined
170
- ;(globalThis as any).frameTimingCollector = undefined
173
+ ; (globalThis as any).world = undefined
174
+ ; (globalThis as any).frameTimingCollector = undefined
171
175
  }
172
176
 
173
177
  if (menuBackgroundRenderer) {
@@ -202,8 +206,8 @@ export const createGraphicsBackendBase = () => {
202
206
  worldRenderer.destroy()
203
207
  worldRenderer = null
204
208
  frameTimingCollector = null
205
- ;(globalThis as any).world = undefined
206
- ;(globalThis as any).frameTimingCollector = undefined
209
+ ; (globalThis as any).world = undefined
210
+ ; (globalThis as any).frameTimingCollector = undefined
207
211
  }
208
212
 
209
213
  const displayOptionsRestorers = [ResourcesManager, WorldViewWorker]
@@ -40,7 +40,7 @@ export function create3DItemMesh (
40
40
  throw new Error(`Invalid canvas dimensions: ${canvas.width}x${canvas.height}`)
41
41
  }
42
42
 
43
- const ctx = canvas.getContext('2d')!
43
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
44
44
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
45
45
  const { data } = imageData
46
46
 
@@ -17,11 +17,6 @@ const FALL_SPEED_MAX = 24
17
17
  const HORIZONTAL_DRIFT = 1.2
18
18
  const RESPAWN_BELOW = -5
19
19
 
20
- const moduleOptions = {
21
- particleCount: 2000,
22
- speedFactor: 1,
23
- }
24
-
25
20
  export class RainModule implements RendererModuleController {
26
21
  private instancedMesh?: THREE.InstancedMesh
27
22
  private geometry?: THREE.BoxGeometry
@@ -32,8 +27,14 @@ export class RainModule implements RendererModuleController {
32
27
  private readonly tempPosition = new THREE.Vector3()
33
28
  private readonly tempQuaternion = new THREE.Quaternion()
34
29
  private readonly tempScale = new THREE.Vector3()
30
+ private readonly configUnsubs: Array<() => void> = []
35
31
 
36
- constructor(private readonly worldRenderer: WorldRendererThree) { }
32
+ constructor(private readonly worldRenderer: WorldRendererThree) {
33
+ this.configUnsubs.push(
34
+ this.worldRenderer.onReactiveConfigUpdated('rainColor', () => this.syncRainAppearance()),
35
+ this.worldRenderer.onReactiveConfigUpdated('rainOpacity', () => this.syncRainAppearance()),
36
+ )
37
+ }
37
38
 
38
39
  enable(): void {
39
40
  if (this.enabled) return
@@ -42,6 +43,7 @@ export class RainModule implements RendererModuleController {
42
43
  this.createRain()
43
44
  } else {
44
45
  this.instancedMesh.visible = true
46
+ this.syncRainAppearance()
45
47
  }
46
48
  }
47
49
 
@@ -60,8 +62,6 @@ export class RainModule implements RendererModuleController {
60
62
  render?: (deltaTime: number) => void = (deltaTime) => {
61
63
  if (!this.enabled || !this.instancedMesh || !this.material) return
62
64
 
63
- this.syncMaterialToSceneFog()
64
-
65
65
  const cameraPos = this.worldRenderer.getCameraPosition()
66
66
  this.instancedMesh.position.set(0, 0, 0)
67
67
 
@@ -126,6 +126,9 @@ export class RainModule implements RendererModuleController {
126
126
  }
127
127
 
128
128
  dispose(): void {
129
+ for (const unsub of this.configUnsubs) unsub()
130
+ this.configUnsubs.length = 0
131
+
129
132
  if (this.instancedMesh) {
130
133
  this.worldRenderer.scene.remove(this.instancedMesh)
131
134
  }
@@ -137,33 +140,31 @@ export class RainModule implements RendererModuleController {
137
140
  this.particles = []
138
141
  }
139
142
 
140
- /** Match scene fog so rain fades with distance instead of a flat blue sheet. */
141
- private syncMaterialToSceneFog(): void {
143
+ private syncRainAppearance(): void {
142
144
  if (!this.material) return
143
- const fog = this.worldRenderer.scene.fog
144
- if (fog instanceof THREE.Fog || fog instanceof THREE.FogExp2) {
145
- this.material.color.copy(fog.color)
146
- } else {
147
- this.material.color.set(0xcc_dd_ee)
148
- }
149
- this.material.fog = true
145
+
146
+ const { rainColor, rainOpacity } = this.worldRenderer.worldRendererConfig
147
+ this.material.color.set(rainColor)
148
+ this.material.opacity = Math.max(0, Math.min(1, rainOpacity))
149
+ this.material.needsUpdate = true
150
150
  }
151
151
 
152
152
  private createRain(): void {
153
+ const { rainColor, rainOpacity } = this.worldRenderer.worldRendererConfig
154
+
153
155
  this.geometry = new THREE.BoxGeometry(0.03, 0.3, 0.03)
154
156
  this.material = new THREE.MeshBasicMaterial({
155
- color: 0xcc_dd_ee,
157
+ color: rainColor,
156
158
  transparent: true,
157
- opacity: 0.35,
159
+ opacity: Math.max(0, Math.min(1, rainOpacity)),
158
160
  // Must write depth so log-depth blocks occlude rain correctly (see cubeBlockShader).
159
161
  depthWrite: true,
160
- fog: true,
162
+ fog: false,
161
163
  })
162
164
 
163
165
  this.instancedMesh = new THREE.InstancedMesh(this.geometry, this.material, PARTICLE_COUNT)
164
166
  this.instancedMesh.name = 'rain-particles'
165
167
  this.instancedMesh.frustumCulled = false
166
- this.syncMaterialToSceneFog()
167
168
 
168
169
  const dummy = new THREE.Matrix4()
169
170
  const position = new THREE.Vector3()
@@ -26,8 +26,10 @@ class StarfieldMaterial extends THREE.ShaderMaterial {
26
26
  uniform float fade;
27
27
  varying vec3 vColor;
28
28
  void main() {
29
- float opacity = 1.0;
30
- gl_FragColor = vec4(vColor, 1.0);
29
+ // fade scales star brightness (0 = invisible). With additive blending this is
30
+ // the only way to dim stars — scene fog never reaches this shader. Driven by the
31
+ // rain state so stars disappear in rain like in vanilla.
32
+ gl_FragColor = vec4(vColor * fade, 1.0);
31
33
 
32
34
  #include <tonemapping_fragment>
33
35
  #include <${threeVersion >= 154 ? 'colorspace_fragment' : 'encodings_fragment'}>
@@ -41,6 +43,8 @@ export class StarfieldModule implements RendererModuleController {
41
43
  private timer = new THREE.Timer()
42
44
  private enabled = false
43
45
  private currentTime?: number
46
+ /** Current star brightness multiplier; lerps toward 0 while raining, 1 otherwise. */
47
+ private fade = 1
44
48
 
45
49
  constructor(private readonly worldRenderer: WorldRendererThree) { }
46
50
 
@@ -72,12 +76,20 @@ export class StarfieldModule implements RendererModuleController {
72
76
  return this.currentTime > nightTime && this.currentTime < morningStart
73
77
  }
74
78
 
75
- render?: (deltaTime: number) => void = (_deltaTime) => {
79
+ render?: (deltaTime: number) => void = (deltaTime) => {
76
80
  if (!this.points) return
77
81
  this.points.position.set(0, 0, 0)
82
+
83
+ const material = this.points.material as StarfieldMaterial
78
84
  this.timer.update(performance.now())
79
- ; (this.points.material as StarfieldMaterial).uniforms.time.value =
80
- this.timer.getElapsed() * 0.2
85
+ material.uniforms.time.value = this.timer.getElapsed() * 0.2
86
+
87
+ // Fade stars out while raining (vanilla scales star brightness by 1 - rainLevel).
88
+ // isRaining is a boolean here, so ease toward the target instead of snapping.
89
+ const target = this.worldRenderer.worldRendererConfig.isRaining ? 0 : 1
90
+ const t = Math.min(1, deltaTime * 2)
91
+ this.fade += (target - this.fade) * t
92
+ material.uniforms.fade.value = this.fade
81
93
  }
82
94
 
83
95
  /**
@@ -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
+ }