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/dist/minecraft-renderer.js +35 -35
- package/dist/minecraft-renderer.js.meta.json +1 -1
- package/dist/threeWorker.js +360 -360
- package/package.json +1 -1
- package/src/three/cloudCover.ts +22 -0
- package/src/three/cloudGeometry.ts +71 -0
- package/src/three/modules/clouds.ts +98 -75
- package/src/three/tests/cloudGeometry.test.ts +87 -0
- package/src/three/tests/clouds.test.ts +65 -14
package/package.json
CHANGED
|
@@ -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 =
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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.
|
|
78
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
102
|
-
this.worldRenderer.scene.remove(this.
|
|
103
|
-
this.
|
|
104
|
-
this.mesh.material.
|
|
105
|
-
this.
|
|
106
|
-
this.
|
|
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
|
|
111
|
-
|
|
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.
|
|
138
|
-
geometry.
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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(
|
|
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
|
-
//
|
|
189
|
+
// Compute in doubles so the GPU only ever sees camera-relative offsets.
|
|
159
190
|
const bottom = worldRendererConfig.cloudHeight + HEIGHT_BIAS
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
182
|
-
this.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
44
|
-
expect(
|
|
42
|
+
const layer = scene.getObjectByName('clouds')!
|
|
43
|
+
expect(layer.visible).toBe(true)
|
|
45
44
|
|
|
46
45
|
module.disable()
|
|
47
|
-
expect(
|
|
46
|
+
expect(layer.visible).toBe(false)
|
|
48
47
|
|
|
49
48
|
module.enable()
|
|
50
|
-
expect(scene.getObjectByName('clouds')).toBe(
|
|
51
|
-
expect(
|
|
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
|
+
})
|