minecraft-renderer 0.1.108 → 0.1.109

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.108",
3
+ "version": "0.1.109",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,22 @@
1
+ //@ts-nocheck
2
+ import type { CloudCover } from './cloudGeometry'
3
+
4
+ /** Texels at least this opaque count as cloud. */
5
+ const ALPHA_THRESHOLD = 128
6
+
7
+ /** Decodes vanilla clouds.png into a per-cell mask (works on the main thread and in workers). */
8
+ export async function loadCloudCover(url: string): Promise<CloudCover> {
9
+ const blob = await (await fetch(url)).blob()
10
+ const bitmap = await createImageBitmap(blob, { premultiplyAlpha: 'none' })
11
+ try {
12
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
13
+ const ctx = canvas.getContext('2d')!
14
+ ctx.drawImage(bitmap, 0, 0)
15
+ const { data } = ctx.getImageData(0, 0, bitmap.width, bitmap.height)
16
+ const filled = new Uint8Array(bitmap.width * bitmap.height)
17
+ for (let i = 0; i < filled.length; i++) filled[i] = data[i * 4 + 3] >= ALPHA_THRESHOLD ? 1 : 0
18
+ return { width: bitmap.width, height: bitmap.height, filled }
19
+ } finally {
20
+ bitmap.close()
21
+ }
22
+ }
@@ -0,0 +1,71 @@
1
+ //@ts-nocheck
2
+ /** Which cells of vanilla clouds.png hold cloud (the texture wraps in both directions). */
3
+ export interface CloudCover {
4
+ width: number
5
+ height: number
6
+ /** Row-major, 1 where the cell has a cloud. */
7
+ filled: Uint8Array
8
+ }
9
+
10
+ export interface CloudGeometryData {
11
+ positions: Float32Array
12
+ /** Per-vertex face brightness. */
13
+ shades: Float32Array
14
+ indices: Uint32Array
15
+ }
16
+
17
+ /** Vanilla: each clouds.png texel is a 12×12 block cell and the slab is 4 blocks thick. */
18
+ export const CLOUD_CELL_SIZE = 12
19
+ export const CLOUD_THICKNESS = 4
20
+
21
+ // Vanilla fancy-cloud face shading.
22
+ const SHADE_TOP = 1
23
+ const SHADE_BOTTOM = 0.7
24
+ const SHADE_NORTH_SOUTH = 0.8
25
+ const SHADE_EAST_WEST = 0.9
26
+
27
+ const mod = (n: number, m: number) => ((n % m) + m) % m
28
+
29
+ /**
30
+ * Builds the slab geometry for the (2 * radius + 1)² cells around pattern cell (centerX, centerZ).
31
+ * Positions are relative to that cell's min corner, y spans 0..CLOUD_THICKNESS. Side faces are only
32
+ * emitted next to empty cells, so the interior of a cloud bank has no hidden geometry.
33
+ * Every quad is wound counter-clockwise seen from outside.
34
+ */
35
+ export function buildCloudGeometry(cover: CloudCover, centerX: number, centerZ: number, radius: number): CloudGeometryData {
36
+ const positions: number[] = []
37
+ const shades: number[] = []
38
+ const indices: number[] = []
39
+
40
+ const isFilled = (cellX: number, cellZ: number) => cover.filled[mod(cellZ, cover.height) * cover.width + mod(cellX, cover.width)] === 1
41
+
42
+ const quad = (shade: number, a: number[], b: number[], c: number[], d: number[]) => {
43
+ const first = positions.length / 3
44
+ positions.push(...a, ...b, ...c, ...d)
45
+ shades.push(shade, shade, shade, shade)
46
+ indices.push(first, first + 1, first + 2, first, first + 2, first + 3)
47
+ }
48
+
49
+ const h = CLOUD_THICKNESS
50
+ for (let i = -radius; i <= radius; i++) {
51
+ for (let j = -radius; j <= radius; j++) {
52
+ const cellX = centerX + i
53
+ const cellZ = centerZ + j
54
+ if (!isFilled(cellX, cellZ)) continue
55
+
56
+ const x0 = i * CLOUD_CELL_SIZE
57
+ const x1 = x0 + CLOUD_CELL_SIZE
58
+ const z0 = j * CLOUD_CELL_SIZE
59
+ const z1 = z0 + CLOUD_CELL_SIZE
60
+
61
+ quad(SHADE_TOP, [x0, h, z0], [x0, h, z1], [x1, h, z1], [x1, h, z0])
62
+ quad(SHADE_BOTTOM, [x0, 0, z0], [x1, 0, z0], [x1, 0, z1], [x0, 0, z1])
63
+ if (!isFilled(cellX + 1, cellZ)) quad(SHADE_EAST_WEST, [x1, 0, z0], [x1, h, z0], [x1, h, z1], [x1, 0, z1])
64
+ if (!isFilled(cellX - 1, cellZ)) quad(SHADE_EAST_WEST, [x0, 0, z1], [x0, h, z1], [x0, h, z0], [x0, 0, z0])
65
+ if (!isFilled(cellX, cellZ + 1)) quad(SHADE_NORTH_SOUTH, [x1, 0, z1], [x1, h, z1], [x0, h, z1], [x0, 0, z1])
66
+ if (!isFilled(cellX, cellZ - 1)) quad(SHADE_NORTH_SOUTH, [x0, 0, z0], [x0, h, z0], [x1, h, z0], [x1, 0, z0])
67
+ }
68
+ }
69
+
70
+ return { positions: new Float32Array(positions), shades: new Float32Array(shades), indices: new Uint32Array(indices) }
71
+ }
@@ -4,30 +4,28 @@ import cloudsPng from 'mc-assets/dist/other-textures/latest/environment/clouds.p
4
4
  import type { WorldRendererThree } from '../worldRendererThree'
5
5
  import type { RendererModuleController, RendererModuleManifest } from '../rendererModuleSystem'
6
6
  import { vanillaCelestialAngle, vanillaDayBrightness } from '../../lib/skyLight'
7
+ import { loadCloudCover } from '../cloudCover'
8
+ import { buildCloudGeometry, CLOUD_CELL_SIZE, type CloudCover } from '../cloudGeometry'
7
9
 
8
- // Vanilla: clouds.png is 256×256, each texel a 12×12 block cell, slab 4 blocks thick.
9
- const CELL_SIZE = 12
10
- const TEXTURE_CELLS = 256
11
- const PATTERN_PERIOD = CELL_SIZE * TEXTURE_CELLS
12
- const THICKNESS = 4
13
10
  /** Vanilla drifts the pattern 0.03 blocks per tick (20 TPS) toward -X. */
14
11
  const DRIFT_BLOCKS_PER_SECOND = 0.03 * 20
15
12
  /** Vanilla offsets the slab bottom by +0.33 from the dimension's cloud height. */
16
13
  const HEIGHT_BIAS = 0.33
17
14
  const MIN_RADIUS = 256
18
- const MAX_RADIUS = 768
15
+ const MAX_RADIUS = 576
19
16
  const OPACITY = 0.8
20
- /** Vanilla shades the underside darker than the top. */
21
- const BOTTOM_SHADE = 0.7
22
17
 
23
18
  const threeVersion = parseInt(THREE.REVISION.replaceAll(/\D+/g, ''), 10)
24
19
 
25
20
  const vertexShader = /* glsl */ `
26
21
  #include <common>
27
22
  #include <logdepthbuf_pars_vertex>
23
+ attribute float shade;
28
24
  varying vec2 vLocal;
25
+ varying float vShade;
29
26
  void main() {
30
27
  vLocal = (modelMatrix * vec4(position, 1.0)).xz;
28
+ vShade = shade;
31
29
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
32
30
  #include <logdepthbuf_vertex>
33
31
  }
@@ -36,32 +34,39 @@ const vertexShader = /* glsl */ `
36
34
  const fragmentShader = /* glsl */ `
37
35
  #include <common>
38
36
  #include <logdepthbuf_pars_fragment>
39
- uniform sampler2D map;
40
- uniform vec2 patternOffset;
41
37
  uniform vec3 color;
42
38
  uniform float opacity;
43
39
  uniform float fadeStart;
44
40
  uniform float fadeEnd;
45
41
  varying vec2 vLocal;
42
+ varying float vShade;
46
43
  void main() {
47
44
  #include <logdepthbuf_fragment>
48
- float cover = texture2D(map, (vLocal + patternOffset) / ${PATTERN_PERIOD.toFixed(1)}).a;
49
- if (cover < 0.5) discard;
50
45
  float alpha = opacity * (1.0 - smoothstep(fadeStart, fadeEnd, length(vLocal)));
51
46
  if (alpha <= 0.0) discard;
52
- gl_FragColor = vec4(color, alpha);
47
+ gl_FragColor = vec4(color * vShade, alpha);
53
48
  #include <tonemapping_fragment>
54
49
  #include <${threeVersion >= 154 ? 'colorspace_fragment' : 'encodings_fragment'}>
55
50
  }
56
51
  `
57
52
 
58
53
  /**
59
- * Flat "fast graphics" clouds: one camera-following quad, the cloud mask is sampled from
60
- * vanilla clouds.png in the fragment shader, so there is no per-cell geometry to rebuild
61
- * as the player moves or the pattern drifts.
54
+ * Vanilla "fancy" clouds: a 4-block-thick slab of 12×12 cells taken from clouds.png, with real top,
55
+ * bottom and side faces. The cells around the camera are rebuilt only when the camera or the drifting
56
+ * pattern crosses a cell boundary; everything finer than that is a mesh position offset.
57
+ * A colour-less depth pre-pass over the same geometry keeps overlapping faces from blending twice.
62
58
  */
63
59
  export class CloudsModule implements RendererModuleController {
64
- private mesh?: THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial>
60
+ private readonly group = new THREE.Group()
61
+ private readonly uniforms = {
62
+ color: { value: new THREE.Color(1, 1, 1) },
63
+ opacity: { value: OPACITY },
64
+ fadeStart: { value: MIN_RADIUS * 0.55 },
65
+ fadeEnd: { value: MIN_RADIUS }
66
+ }
67
+ private meshes?: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>[]
68
+ private cover?: CloudCover
69
+ private builtKey = ''
65
70
  private enabled = false
66
71
  private drift = 0
67
72
  private timeOfDay: number
@@ -69,19 +74,20 @@ export class CloudsModule implements RendererModuleController {
69
74
 
70
75
  constructor(private readonly worldRenderer: WorldRendererThree) {
71
76
  this.timeOfDay = worldRenderer.timeOfTheDay
77
+ this.group.name = 'clouds'
72
78
  }
73
79
 
74
80
  enable(): void {
75
81
  if (this.enabled) return
76
82
  this.enabled = true
77
- if (!this.mesh) this.create()
78
- this.mesh!.visible = true
83
+ if (!this.meshes) this.create()
84
+ this.group.visible = true
79
85
  }
80
86
 
81
87
  disable(): void {
82
88
  if (!this.enabled) return
83
89
  this.enabled = false
84
- if (this.mesh) this.mesh.visible = false
90
+ this.group.visible = false
85
91
  }
86
92
 
87
93
  autoEnableCheck(): boolean {
@@ -94,78 +100,96 @@ export class CloudsModule implements RendererModuleController {
94
100
  }
95
101
 
96
102
  render?: (deltaTime: number) => void = deltaTime => {
97
- this.drift = (this.drift + deltaTime * DRIFT_BLOCKS_PER_SECOND) % PATTERN_PERIOD
103
+ // Not wrapped: cell indices wrap in the lookup, and wrapping here would force a rebuild.
104
+ this.drift += deltaTime * DRIFT_BLOCKS_PER_SECOND
98
105
  }
99
106
 
100
107
  dispose(): void {
101
- if (!this.mesh) return
102
- this.worldRenderer.scene.remove(this.mesh)
103
- this.mesh.geometry.dispose()
104
- this.mesh.material.uniforms.map.value?.dispose()
105
- this.mesh.material.dispose()
106
- this.mesh = undefined
108
+ if (!this.meshes) return
109
+ this.worldRenderer.scene.remove(this.group)
110
+ this.meshes[0].geometry.dispose()
111
+ for (const mesh of this.meshes) mesh.material.dispose()
112
+ this.meshes = undefined
113
+ this.cover = undefined
114
+ this.builtKey = ''
107
115
  }
108
116
 
109
117
  private create(): void {
110
- const map = new THREE.TextureLoader().load(cloudsPng)
111
- map.wrapS = THREE.RepeatWrapping
112
- map.wrapT = THREE.RepeatWrapping
113
- map.magFilter = THREE.NearestFilter
114
- map.minFilter = THREE.NearestFilter
115
- map.generateMipmaps = false
116
- // Keep png row 0 at -Z like vanilla's texture-to-world mapping.
117
- map.flipY = false
118
-
119
- const material = new THREE.ShaderMaterial({
120
- uniforms: {
121
- map: { value: map },
122
- patternOffset: { value: new THREE.Vector2() },
123
- color: { value: new THREE.Color(1, 1, 1) },
124
- opacity: { value: OPACITY },
125
- fadeStart: { value: MIN_RADIUS * 0.55 },
126
- fadeEnd: { value: MIN_RADIUS }
127
- },
118
+ const shared = {
119
+ uniforms: this.uniforms,
128
120
  vertexShader,
129
121
  fragmentShader,
130
- transparent: true,
131
- depthWrite: false,
132
- depthTest: true,
133
122
  side: THREE.DoubleSide,
123
+ transparent: true,
134
124
  forceSinglePass: true
135
- })
125
+ }
126
+ const main = new THREE.ShaderMaterial({ ...shared, depthWrite: false })
127
+ // Same program, so its depth matches the main pass exactly.
128
+ const depthOnly = new THREE.ShaderMaterial({ ...shared, depthWrite: true, colorWrite: false })
136
129
 
137
- const geometry = new THREE.PlaneGeometry(2, 2)
138
- geometry.rotateX(-Math.PI / 2)
130
+ const geometry = new THREE.BufferGeometry()
131
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(0), 3))
139
132
 
140
- const mesh = new THREE.Mesh(geometry, material)
141
- mesh.name = 'clouds'
142
- mesh.frustumCulled = false
143
133
  // After sun/moon (-2) and stars (-1), before other transparents so water/glass composite on top.
144
- mesh.renderOrder = -0.5
145
- mesh.onBeforeRender = () => this.syncToCamera(mesh)
146
- this.mesh = mesh
147
- this.worldRenderer.scene.add(mesh)
134
+ const passes: [THREE.ShaderMaterial, number][] = [
135
+ [depthOnly, -0.6],
136
+ [main, -0.5]
137
+ ]
138
+ this.meshes = passes.map(([material, renderOrder]) => {
139
+ const mesh = new THREE.Mesh(geometry, material)
140
+ mesh.frustumCulled = false
141
+ mesh.renderOrder = renderOrder
142
+ this.group.add(mesh)
143
+ return mesh
144
+ })
145
+ // The depth pre-pass is drawn first, so syncing there positions both passes.
146
+ this.meshes[0].onBeforeRender = () => this.syncToCamera()
147
+ this.worldRenderer.scene.add(this.group)
148
+
149
+ loadCloudCover(cloudsPng).then(
150
+ cover => {
151
+ this.cover = cover
152
+ },
153
+ error => console.warn('Failed to load clouds.png, clouds will not render', error)
154
+ )
155
+ }
156
+
157
+ private rebuild(centerX: number, centerZ: number, radiusCells: number): void {
158
+ const data = buildCloudGeometry(this.cover!, centerX, centerZ, radiusCells)
159
+ const geometry = new THREE.BufferGeometry()
160
+ geometry.setAttribute('position', new THREE.BufferAttribute(data.positions, 3))
161
+ geometry.setAttribute('shade', new THREE.BufferAttribute(data.shades, 1))
162
+ geometry.setIndex(new THREE.BufferAttribute(data.indices, 1))
163
+
164
+ const meshes = this.meshes!
165
+ const old = meshes[0].geometry
166
+ for (const mesh of meshes) mesh.geometry = geometry
167
+ old.dispose()
148
168
  }
149
169
 
150
- private syncToCamera(mesh: THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial>): void {
170
+ private syncToCamera(): void {
151
171
  const { sceneOrigin, viewDistance, worldRendererConfig } = this.worldRenderer
152
- const { uniforms } = mesh.material
153
172
 
154
173
  const radius = THREE.MathUtils.clamp(viewDistance * 16 * 2, MIN_RADIUS, MAX_RADIUS)
155
- uniforms.fadeStart.value = radius * 0.55
156
- uniforms.fadeEnd.value = radius
174
+ this.uniforms.fadeStart.value = radius * 0.55
175
+ this.uniforms.fadeEnd.value = radius
176
+
177
+ // Pattern space is world space shifted by the drift, so world x = pattern x - drift.
178
+ const centerX = Math.floor((sceneOrigin.x + this.drift) / CLOUD_CELL_SIZE)
179
+ const centerZ = Math.floor(sceneOrigin.z / CLOUD_CELL_SIZE)
180
+ if (this.cover) {
181
+ const radiusCells = Math.ceil(radius / CLOUD_CELL_SIZE)
182
+ const key = `${centerX},${centerZ},${radiusCells}`
183
+ if (key !== this.builtKey) {
184
+ this.builtKey = key
185
+ this.rebuild(centerX, centerZ, radiusCells)
186
+ }
187
+ }
157
188
 
158
- // Show the slab bottom from below and its top from above, like a 4-block-thick cloud.
189
+ // Compute in doubles so the GPU only ever sees camera-relative offsets.
159
190
  const bottom = worldRendererConfig.cloudHeight + HEIGHT_BIAS
160
- const fromBelow = sceneOrigin.y < bottom + THICKNESS / 2
161
- const planeY = fromBelow ? bottom : bottom + THICKNESS
162
- mesh.position.set(0, planeY - sceneOrigin.y, 0)
163
- mesh.scale.set(radius, 1, radius)
164
- mesh.updateMatrixWorld()
165
-
166
- // Wrap on the CPU in doubles so the shader never sees large world coordinates.
167
- const wrap = (v: number) => ((v % PATTERN_PERIOD) + PATTERN_PERIOD) % PATTERN_PERIOD
168
- uniforms.patternOffset.value.set(wrap(sceneOrigin.x + this.drift), wrap(sceneOrigin.z))
191
+ this.group.position.set(centerX * CLOUD_CELL_SIZE - this.drift - sceneOrigin.x, bottom - sceneOrigin.y, centerZ * CLOUD_CELL_SIZE - sceneOrigin.z)
192
+ this.group.updateMatrixWorld(true)
169
193
 
170
194
  const day = vanillaDayBrightness(vanillaCelestialAngle(this.timeOfDay))
171
195
  let r = day * 0.9 + 0.1
@@ -178,9 +202,8 @@ export class CloudsModule implements RendererModuleController {
178
202
  g = g * 0.05 + grey * 0.95
179
203
  b = b * 0.05 + grey * 0.95
180
204
  }
181
- const shade = fromBelow ? BOTTOM_SHADE : 1
182
- this.linearColor.setRGB(r * shade, g * shade, b * shade, THREE.SRGBColorSpace)
183
- uniforms.color.value.copy(this.linearColor)
205
+ this.linearColor.setRGB(r, g, b, THREE.SRGBColorSpace)
206
+ this.uniforms.color.value.copy(this.linearColor)
184
207
  }
185
208
  }
186
209
 
@@ -0,0 +1,87 @@
1
+ //@ts-nocheck
2
+ import { expect, test } from 'vitest'
3
+ import { buildCloudGeometry, CLOUD_CELL_SIZE, CLOUD_THICKNESS, type CloudCover } from '../cloudGeometry'
4
+
5
+ function coverOf(rows: string[]): CloudCover {
6
+ const height = rows.length
7
+ const width = rows[0].length
8
+ const filled = new Uint8Array(width * height)
9
+ rows.forEach((row, z) => [...row].forEach((ch, x) => (filled[z * width + x] = ch === '#' ? 1 : 0)))
10
+ return { width, height, filled }
11
+ }
12
+
13
+ const quadCount = (data: ReturnType<typeof buildCloudGeometry>) => data.indices.length / 6
14
+
15
+ /** Normal of triangle `tri` from the index buffer. */
16
+ function triangleNormal(data: ReturnType<typeof buildCloudGeometry>, tri: number): [number, number, number] {
17
+ const p = (k: number) => {
18
+ const v = data.indices[tri * 3 + k] * 3
19
+ return [data.positions[v], data.positions[v + 1], data.positions[v + 2]]
20
+ }
21
+ const [a, b, c] = [p(0), p(1), p(2)]
22
+ const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]
23
+ const v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]
24
+ return [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]]
25
+ }
26
+
27
+ test('a lone cell is a closed box of six faces', () => {
28
+ const data = buildCloudGeometry(coverOf(['....', '.#..', '....', '....']), 1, 1, 0)
29
+ expect(quadCount(data)).toBe(6)
30
+ expect(data.positions.length / 3).toBe(24)
31
+ expect(data.shades.length).toBe(24)
32
+ })
33
+
34
+ test('empty sky produces no geometry', () => {
35
+ expect(buildCloudGeometry(coverOf(['....', '....']), 0, 0, 3).indices.length).toBe(0)
36
+ })
37
+
38
+ test('faces between adjacent cells are not emitted', () => {
39
+ // top + bottom for each cell, two side faces each along z, and one end cap each along x.
40
+ const data = buildCloudGeometry(coverOf(['....', '.##.', '....', '....']), 1, 1, 1)
41
+ expect(quadCount(data)).toBe(2 + 2 + 4 + 2)
42
+ })
43
+
44
+ test('every face winds counter-clockwise seen from outside the box', () => {
45
+ const data = buildCloudGeometry(coverOf(['....', '.#..', '....', '....']), 1, 1, 0)
46
+ const normals = new Set<string>()
47
+ for (let tri = 0; tri < data.indices.length / 3; tri++) {
48
+ const n = triangleNormal(data, tri)
49
+ // Only one axis is non-zero for an axis-aligned face.
50
+ const nonZero = n.filter(c => Math.abs(c) > 1e-6)
51
+ expect(nonZero).toHaveLength(1)
52
+ normals.add(n.map(c => Math.sign(c)).join(','))
53
+ }
54
+ expect([...normals].sort()).toEqual(['-1,0,0', '0,-1,0', '0,0,-1', '0,0,1', '0,1,0', '1,0,0'].sort())
55
+ })
56
+
57
+ test('faces point away from the cell centre', () => {
58
+ const data = buildCloudGeometry(coverOf(['.#', '..']), 1, 0, 0)
59
+ const centre = [CLOUD_CELL_SIZE / 2, CLOUD_THICKNESS / 2, CLOUD_CELL_SIZE / 2]
60
+ for (let tri = 0; tri < data.indices.length / 3; tri++) {
61
+ const n = triangleNormal(data, tri)
62
+ const v = data.indices[tri * 3] * 3
63
+ const toFace = [data.positions[v] - centre[0], data.positions[v + 1] - centre[1], data.positions[v + 2] - centre[2]]
64
+ expect(n[0] * toFace[0] + n[1] * toFace[1] + n[2] * toFace[2]).toBeGreaterThan(0)
65
+ }
66
+ })
67
+
68
+ test('the slab is four blocks thick and cells are 12 blocks wide', () => {
69
+ const data = buildCloudGeometry(coverOf(['#']), 0, 0, 0)
70
+ const xs = [...data.positions].filter((_, i) => i % 3 === 0)
71
+ const ys = [...data.positions].filter((_, i) => i % 3 === 1)
72
+ expect(Math.max(...ys) - Math.min(...ys)).toBe(4)
73
+ expect(Math.max(...xs) - Math.min(...xs)).toBe(12)
74
+ })
75
+
76
+ test('cell lookup wraps like the texture, including negative indices', () => {
77
+ const cover = coverOf(['#...', '....'])
78
+ // Cells (-4, 0) and (4, 2) alias texel (0, 0); a lone filled texel yields exactly one box per alias.
79
+ expect(quadCount(buildCloudGeometry(cover, -4, 0, 0))).toBe(6)
80
+ expect(quadCount(buildCloudGeometry(cover, 4, 2, 0))).toBe(6)
81
+ expect(quadCount(buildCloudGeometry(cover, 1, 0, 0))).toBe(0)
82
+ })
83
+
84
+ test('shading matches vanilla: top brightest, bottom darkest, sides in between', () => {
85
+ const data = buildCloudGeometry(coverOf(['....', '.#..', '....', '....']), 1, 1, 0)
86
+ expect([...new Set(data.shades)].sort()).toEqual([0.7, 0.8, 0.9, 1].map(Math.fround).sort())
87
+ })
@@ -1,17 +1,14 @@
1
1
  //@ts-nocheck
2
2
  import * as THREE from 'three'
3
- import { afterEach, beforeEach, expect, test, vi } from 'vitest'
3
+ import { expect, test, vi } from 'vitest'
4
4
  import { CloudsModule } from '../modules/clouds'
5
5
  import { defaultWorldRendererConfig } from '../../graphicsBackend/config'
6
+ import type { CloudCover } from '../cloudGeometry'
6
7
 
7
- beforeEach(() => {
8
- // No image decoding in node; the module only needs a texture object.
9
- vi.spyOn(THREE.TextureLoader.prototype, 'load').mockImplementation(() => new THREE.Texture())
10
- })
8
+ const cover: CloudCover = { width: 2, height: 2, filled: new Uint8Array([1, 0, 0, 1]) }
11
9
 
12
- afterEach(() => {
13
- vi.restoreAllMocks()
14
- })
10
+ // No image decoding in node.
11
+ vi.mock('../cloudCover', () => ({ loadCloudCover: vi.fn(async () => cover) }))
15
12
 
16
13
  function createModule(config: Partial<typeof defaultWorldRendererConfig> = {}) {
17
14
  const scene = new THREE.Scene()
@@ -26,6 +23,8 @@ function createModule(config: Partial<typeof defaultWorldRendererConfig> = {}) {
26
23
  return { module, scene, worldRenderer }
27
24
  }
28
25
 
26
+ const cloudMeshes = (scene: THREE.Scene) => scene.getObjectByName('clouds')!.children as THREE.Mesh[]
27
+
29
28
  test('clouds are on by default and auto mode follows the clouds option', () => {
30
29
  const { module, worldRenderer } = createModule()
31
30
  expect(module.autoEnableCheck()).toBe(true)
@@ -37,18 +36,18 @@ test('clouds stay off in the nether even when enabled', () => {
37
36
  expect(createModule({ cardinalLight: 'nether' }).module.autoEnableCheck()).toBe(false)
38
37
  })
39
38
 
40
- test('disable hides the cloud layer and enable shows the same mesh again', () => {
39
+ test('disable hides the cloud layer and enable shows the same layer again', () => {
41
40
  const { module, scene } = createModule()
42
41
  module.enable()
43
- const mesh = scene.getObjectByName('clouds')!
44
- expect(mesh.visible).toBe(true)
42
+ const layer = scene.getObjectByName('clouds')!
43
+ expect(layer.visible).toBe(true)
45
44
 
46
45
  module.disable()
47
- expect(mesh.visible).toBe(false)
46
+ expect(layer.visible).toBe(false)
48
47
 
49
48
  module.enable()
50
- expect(scene.getObjectByName('clouds')).toBe(mesh)
51
- expect(mesh.visible).toBe(true)
49
+ expect(scene.getObjectByName('clouds')).toBe(layer)
50
+ expect(layer.visible).toBe(true)
52
51
  })
53
52
 
54
53
  test('dispose removes the cloud layer from the scene', () => {
@@ -57,3 +56,55 @@ test('dispose removes the cloud layer from the scene', () => {
57
56
  module.dispose()
58
57
  expect(scene.getObjectByName('clouds')).toBeUndefined()
59
58
  })
59
+
60
+ test('renders a depth pre-pass before the colour pass over the same geometry', () => {
61
+ const { module, scene } = createModule()
62
+ module.enable()
63
+ const [depthPass, colorPass] = cloudMeshes(scene)
64
+ expect(depthPass.material).toMatchObject({ colorWrite: false, depthWrite: true })
65
+ expect(colorPass.material).toMatchObject({ colorWrite: true, depthWrite: false })
66
+ expect(depthPass.renderOrder).toBeLessThan(colorPass.renderOrder)
67
+ expect(depthPass.geometry).toBe(colorPass.geometry)
68
+ })
69
+
70
+ test('builds volumetric cloud geometry once clouds.png has loaded and follows the camera', async () => {
71
+ const { module, scene, worldRenderer } = createModule()
72
+ module.enable()
73
+ const [depthPass, colorPass] = cloudMeshes(scene)
74
+ const sync = () => (depthPass.onBeforeRender as any)()
75
+
76
+ // Nothing to draw until the texture is decoded.
77
+ sync()
78
+ expect(depthPass.geometry.index).toBeNull()
79
+
80
+ await vi.waitFor(() => {
81
+ sync()
82
+ expect(depthPass.geometry.index).not.toBeNull()
83
+ })
84
+ const geometry = depthPass.geometry
85
+ expect(colorPass.geometry).toBe(geometry)
86
+ // Side faces and a 4-block thickness: more than a flat sheet's top/bottom quads.
87
+ const ys = geometry.getAttribute('position').array.filter((_, i) => i % 3 === 1)
88
+ expect(Math.max(...ys) - Math.min(...ys)).toBe(4)
89
+ expect(geometry.getAttribute('shade')).toBeDefined()
90
+
91
+ // Moving within the same cell keeps the geometry and only moves the layer.
92
+ const before = scene.getObjectByName('clouds')!.position.x
93
+ worldRenderer.sceneOrigin.x += 3
94
+ sync()
95
+ expect(depthPass.geometry).toBe(geometry)
96
+ expect(scene.getObjectByName('clouds')!.position.x).toBeCloseTo(before - 3)
97
+
98
+ // Crossing a cell boundary rebuilds around the new cell.
99
+ worldRenderer.sceneOrigin.x += 24
100
+ sync()
101
+ expect(depthPass.geometry).not.toBe(geometry)
102
+ })
103
+
104
+ test('the layer sits at the configured cloud height above the camera', () => {
105
+ const { module, scene, worldRenderer } = createModule({ cloudHeight: 192 })
106
+ worldRenderer.sceneOrigin.y = 100
107
+ module.enable()
108
+ ;(cloudMeshes(scene)[0].onBeforeRender as any)()
109
+ expect(scene.getObjectByName('clouds')!.position.y).toBeCloseTo(192.33 - 100)
110
+ })