minecraft-renderer 0.1.107 → 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.
Files changed (95) hide show
  1. package/dist/lightOwnerWorker.js +9 -0
  2. package/dist/lightOwnerWorker.js.map +7 -0
  3. package/dist/mesher.js +1 -1
  4. package/dist/mesher.js.map +2 -2
  5. package/dist/mesherWasm.js +222 -222
  6. package/dist/minecraft-renderer.js +84 -84
  7. package/dist/minecraft-renderer.js.meta.json +1 -1
  8. package/dist/threeWorker.js +517 -517
  9. package/package.json +1 -1
  10. package/src/bundler/bundlePrepare.test.ts +25 -0
  11. package/src/bundler/bundlePrepare.ts +1 -1
  12. package/src/graphicsBackend/appViewer.ts +15 -0
  13. package/src/graphicsBackend/config.ts +5 -0
  14. package/src/graphicsBackend/rendererDefaultOptions.test.ts +51 -0
  15. package/src/graphicsBackend/rendererDefaultOptions.ts +13 -1
  16. package/src/graphicsBackend/rendererOptionsSync.ts +3 -3
  17. package/src/lib/blockEntityLightRegistry.ts +4 -0
  18. package/src/lib/worldrendererCommon.lightOwner.test.ts +916 -0
  19. package/src/lib/worldrendererCommon.removeColumn.test.ts +16 -0
  20. package/src/lib/worldrendererCommon.ts +329 -67
  21. package/src/lighting/cache/rendererLightCache.test.ts +158 -0
  22. package/src/lighting/cache/rendererLightCache.ts +248 -0
  23. package/src/lighting/coordinator/clientLightCoordinator.test.ts +102 -0
  24. package/src/lighting/coordinator/clientLightCoordinator.ts +574 -0
  25. package/src/lighting/debug/clientLightProbe.test.ts +24 -0
  26. package/src/lighting/debug/clientLightProbe.ts +20 -0
  27. package/src/lighting/debug/clientLightTrace.test.ts +271 -0
  28. package/src/lighting/debug/clientLightTrace.ts +335 -0
  29. package/src/lighting/entity/entityLightController.test.ts +216 -0
  30. package/src/lighting/entity/entityLightController.ts +322 -0
  31. package/src/lighting/owner/clientLightOwner.test.ts +467 -0
  32. package/src/lighting/owner/clientLightOwner.ts +523 -0
  33. package/src/lighting/owner/lightOwnerHost.test.ts +78 -0
  34. package/src/lighting/owner/lightOwnerHost.ts +202 -0
  35. package/src/lighting/owner/lightOwnerWorker.ts +154 -0
  36. package/src/lighting/owner/updateLight1171OwnerPath.test.ts +68 -0
  37. package/src/lighting/owner/updateLightToOwnerEvents.ts +49 -0
  38. package/src/lighting/tables/lightTables1171.generated.json +10 -0
  39. package/src/lighting/tables/lightTables1171.test.ts +168 -0
  40. package/src/lighting/tables/lightTables1171.ts +59 -0
  41. package/src/lighting/versioning/clientLightVersions.test.ts +241 -0
  42. package/src/lighting/versioning/clientLightVersions.ts +203 -0
  43. package/src/lighting/versioning/pendingSectionFlush.test.ts +214 -0
  44. package/src/lighting/versioning/pendingSectionFlush.ts +123 -0
  45. package/src/mesher-shared/lightNibblePack.ts +26 -0
  46. package/src/mesher-shared/mcDataWorkerShim.cjs +9 -7
  47. package/src/mesher-shared/modelsGeometryCommon.test.ts +6 -1
  48. package/src/mesher-shared/shared.ts +48 -1
  49. package/src/three/chunkMeshManager.ts +26 -0
  50. package/src/three/cloudCover.ts +22 -0
  51. package/src/three/cloudGeometry.ts +71 -0
  52. package/src/three/entities.ts +77 -11
  53. package/src/three/entity/EntityMesh.ts +1 -0
  54. package/src/three/entity/legWalkAnimation.test.ts +11 -2
  55. package/src/three/entity/legWalkAnimation.ts +1 -9
  56. package/src/three/globalBlockBuffer.ts +94 -2
  57. package/src/three/globalLegacyBuffer.ts +94 -2
  58. package/src/three/graphicsBackendBase.ts +2 -9
  59. package/src/three/modules/clouds.ts +98 -75
  60. package/src/three/tests/cloudGeometry.test.ts +87 -0
  61. package/src/three/tests/clouds.test.ts +65 -14
  62. package/src/three/tests/globalBlockBuffer.test.ts +98 -0
  63. package/src/three/tests/globalLegacyBuffer.test.ts +108 -0
  64. package/src/three/worldRendererThree.ts +75 -34
  65. package/src/wasm-mesher/bridge/render-from-wasm.ts +46 -1
  66. package/src/wasm-mesher/runtime-build/wasm_mesher.d.ts +31 -5
  67. package/src/wasm-mesher/runtime-build/wasm_mesher.js +180 -5
  68. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm +0 -0
  69. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts +11 -0
  70. package/src/wasm-mesher/tests/mesherLightDependencies.test.ts +83 -0
  71. package/src/wasm-mesher/tests/mesherWasmColumnTickSelect.test.ts +39 -0
  72. package/src/wasm-mesher/tests/mesherWasmConversionCache.test.ts +30 -1
  73. package/src/wasm-mesher/tests/mesherWasmTickParseCache.test.ts +73 -0
  74. package/src/wasm-mesher/tests/mesherWasmTickSchedule.test.ts +51 -0
  75. package/src/wasm-mesher/tests/mesherWasmTopologyPostCache.test.ts +78 -0
  76. package/src/wasm-mesher/worker/clientLightDarkPublication.probe.test.ts +32 -0
  77. package/src/wasm-mesher/worker/mesherLightDependencies.ts +43 -0
  78. package/src/wasm-mesher/worker/mesherWasm.ownerLightSync.test.ts +198 -0
  79. package/src/wasm-mesher/worker/mesherWasm.ts +506 -96
  80. package/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts +1 -0
  81. package/src/wasm-mesher/worker/mesherWasmColumnTickSelect.ts +47 -0
  82. package/src/wasm-mesher/worker/mesherWasmConversionCache.ts +16 -5
  83. package/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts +17 -1
  84. package/src/wasm-mesher/worker/mesherWasmLightDirty.ts +10 -0
  85. package/src/wasm-mesher/worker/mesherWasmLightMerge.test.ts +208 -0
  86. package/src/wasm-mesher/worker/mesherWasmLightMerge.ts +228 -0
  87. package/src/wasm-mesher/worker/mesherWasmOwnerLight.test.ts +161 -0
  88. package/src/wasm-mesher/worker/mesherWasmOwnerLight.ts +112 -0
  89. package/src/wasm-mesher/worker/mesherWasmTickParseCache.ts +45 -0
  90. package/src/wasm-mesher/worker/mesherWasmTickSchedule.ts +44 -0
  91. package/src/wasm-mesher/worker/mesherWasmTopologyPostCache.ts +63 -0
  92. package/src/worldView/types.ts +1 -0
  93. package/src/worldView/worldView.geometryReload.test.ts +68 -0
  94. package/src/worldView/worldView.spiral.test.ts +1 -1
  95. package/src/worldView/worldView.ts +46 -1
@@ -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
+ })
@@ -215,3 +215,101 @@ test('GlobalBlockBuffer: uploadEpoch increments when dirty range drains', () =>
215
215
  buffer.dispose()
216
216
  mat.dispose()
217
217
  })
218
+
219
+ function faceWords(faceCount: number): Uint32Array {
220
+ return new Uint32Array(faceCount * 4)
221
+ }
222
+
223
+ function drawableCubeFaces(buffer: GlobalBlockBuffer, key: string): number {
224
+ const start = buffer.getSectionDrawStart(key)
225
+ const count = buffer.getSectionDrawCount(key)
226
+ if (start === undefined || count === undefined) return 0
227
+ const spans = buildVisibleCubeSpans([{ start, count }], buffer.getHighWatermark(), false, undefined, buffer.getPendingDirtyRanges())
228
+ return spans.reduce((sum, span) => sum + span.count, 0)
229
+ }
230
+
231
+ test('GlobalBlockBuffer: compaction must not hide pending-replace displayed faces', () => {
232
+ const scene = new THREE.Scene()
233
+ const mat = createCubeBlockMaterial()
234
+ const buffer = new GlobalBlockBuffer(mat, scene)
235
+
236
+ buffer.addSection('H', faceWords(20_000), 20_000)
237
+ buffer.addSection('A', faceWords(8000), 8000)
238
+ buffer.addSection('B', faceWords(1000), 1000)
239
+ drainUploads(buffer)
240
+ const original = buffer.getSectionDrawStart('A')
241
+
242
+ buffer.addSection('A', faceWords(8000), 8000)
243
+ buffer.removeSection('H')
244
+ buffer.compactStep()
245
+ buffer.uploadDirtyRange()
246
+
247
+ expect(drawableCubeFaces(buffer, 'A')).toBe(8000)
248
+ expect(buffer.getSectionDrawStart('A')).toBe(original)
249
+ const move = buffer.getPendingMove()
250
+ expect(move?.key === 'A').toBe(false)
251
+
252
+ buffer.dispose()
253
+ mat.dispose()
254
+ })
255
+
256
+ test('GlobalBlockBuffer: 27 simultaneous remeshes keep 8000 faces on every frame', () => {
257
+ const scene = new THREE.Scene()
258
+ const mat = createCubeBlockMaterial()
259
+ const buffer = new GlobalBlockBuffer(mat, scene)
260
+ const n = 27
261
+ const faces = 8000
262
+
263
+ for (let i = 0; i < n; i++) buffer.addSection(String(i), faceWords(faces), faces)
264
+ drainUploads(buffer)
265
+ for (let i = 0; i < n; i++) buffer.addSection(String(i), faceWords(faces), faces)
266
+
267
+ for (let frame = 1; frame <= 120; frame++) {
268
+ buffer.compactStep()
269
+ buffer.uploadDirtyRange()
270
+ for (let i = 0; i < n; i++) {
271
+ expect(drawableCubeFaces(buffer, String(i)), `section ${i} on frame ${frame}`).toBe(faces)
272
+ }
273
+ if (!buffer.hasPendingUploads() && !buffer.hasPendingReplace() && !buffer.getPendingMove()) break
274
+ }
275
+
276
+ buffer.dispose()
277
+ mat.dispose()
278
+ })
279
+
280
+ test('GlobalBlockBuffer: remesh during move keeps original displayed slot', () => {
281
+ const scene = new THREE.Scene()
282
+ const mat = createCubeBlockMaterial()
283
+ const buffer = new GlobalBlockBuffer(mat, scene)
284
+
285
+ buffer.addSection('H', faceWords(20_000), 20_000)
286
+ buffer.addSection('B', faceWords(1000), 1000)
287
+ buffer.addSection('A', faceWords(8000), 8000)
288
+ drainUploads(buffer)
289
+ buffer.removeSection('H')
290
+ drainUploads(buffer)
291
+ buffer.compactStep()
292
+ expect(buffer.getPendingMove()?.key).toBe('A')
293
+ const displayed = buffer.getSectionDrawStart('A')
294
+ expect(displayed).toBeDefined()
295
+
296
+ buffer.addSection('A', faceWords(8000), 8000)
297
+ expect(buffer.getSectionDrawStart('A')).toBe(displayed)
298
+ expect(buffer.getPendingMove()).toBeNull()
299
+ expect(buffer.hasPendingReplace()).toBe(true)
300
+ expect(drawableCubeFaces(buffer, 'A')).toBe(8000)
301
+
302
+ buffer.compactStep()
303
+ buffer.uploadDirtyRange()
304
+ expect(buffer.getSectionDrawStart('A')).toBe(displayed)
305
+ expect(buffer.getPendingMove()?.key === 'A').toBe(false)
306
+ expect(drawableCubeFaces(buffer, 'A')).toBe(8000)
307
+
308
+ drainUploads(buffer)
309
+ buffer.compactStep()
310
+ finishCurrentMove(buffer)
311
+ expect(drawableCubeFaces(buffer, 'A')).toBe(8000)
312
+
313
+ buffer.dispose()
314
+ mat.dispose()
315
+ })
@@ -892,3 +892,111 @@ test('GlobalLegacyBuffer: blend reorder metadata survives compaction', () => {
892
892
  buffer.dispose()
893
893
  mat.dispose()
894
894
  })
895
+
896
+ function quadGeometry(quadCount: number): ReturnType<typeof makeQuadGeometry> {
897
+ const indices = new Uint32Array(quadCount * 6)
898
+ for (let i = 0; i < quadCount; i++) {
899
+ const v = i * 4
900
+ indices.set([v, v + 1, v + 2, v, v + 2, v + 3], i * 6)
901
+ }
902
+ return {
903
+ positions: new Float32Array(quadCount * 12),
904
+ colors: new Float32Array(quadCount * 12),
905
+ skyLights: new Float32Array(quadCount * 4),
906
+ blockLights: new Float32Array(quadCount * 4),
907
+ uvs: new Float32Array(quadCount * 8),
908
+ indices
909
+ }
910
+ }
911
+
912
+ function drawableLegacyIndices(buffer: GlobalLegacyBuffer, key: string): number {
913
+ buffer.updateDrawSpans([{ key, distSq: 0 }], 'opaque')
914
+ return buffer.getVisibleIndexSpans().reduce((sum, span) => sum + span.indexCount, 0)
915
+ }
916
+
917
+ test('GlobalLegacyBuffer: compaction must not hide pending-replace displayed indices', () => {
918
+ const scene = new THREE.Scene()
919
+ const mat = createGlobalLegacyBlockMaterial()
920
+ const buffer = new GlobalLegacyBuffer(mat, scene)
921
+
922
+ buffer.addSection('H', quadGeometry(8000), 0, 0, 0)
923
+ buffer.addSection('A', quadGeometry(3000), 0, 0, 0)
924
+ buffer.addSection('B', quadGeometry(500), 0, 0, 0)
925
+ drainUploads(buffer)
926
+ const original = buffer.getSectionDrawStart('A')
927
+
928
+ buffer.addSection('A', quadGeometry(3000), 0, 0, 0)
929
+ buffer.removeSection('H')
930
+ buffer.compactStep()
931
+ buffer.uploadDirtyRange()
932
+ buffer.updateDrawSpans([{ key: 'A', distSq: 0 }], 'opaque')
933
+
934
+ expect(drawableLegacyIndices(buffer, 'A')).toBe(18_000)
935
+ expect(buffer.getSectionDrawStart('A')).toBe(original)
936
+ expect(buffer.getPendingMove()?.key === 'A').toBe(false)
937
+
938
+ buffer.dispose()
939
+ mat.dispose()
940
+ })
941
+
942
+ test('GlobalLegacyBuffer: 27 simultaneous remeshes keep displayed quads on every frame', () => {
943
+ const scene = new THREE.Scene()
944
+ const mat = createGlobalLegacyBlockMaterial()
945
+ const buffer = new GlobalLegacyBuffer(mat, scene)
946
+ const n = 27
947
+ const quads = 1000
948
+ const expectedIndices = quads * 6
949
+
950
+ for (let i = 0; i < n; i++) buffer.addSection(String(i), quadGeometry(quads), 0, 0, 0)
951
+ drainUploads(buffer)
952
+ for (let i = 0; i < n; i++) buffer.addSection(String(i), quadGeometry(quads), 0, 0, 0)
953
+
954
+ for (let frame = 1; frame <= 160; frame++) {
955
+ buffer.compactStep()
956
+ buffer.uploadDirtyRange()
957
+ for (let i = 0; i < n; i++) {
958
+ expect(drawableLegacyIndices(buffer, String(i)), `section ${i} on frame ${frame}`).toBe(expectedIndices)
959
+ }
960
+ if (!buffer.hasPendingUploads() && !buffer.hasPendingReplace() && !buffer.getPendingMove()) break
961
+ }
962
+
963
+ buffer.dispose()
964
+ mat.dispose()
965
+ })
966
+
967
+ test('GlobalLegacyBuffer: remesh during move keeps original displayed slot', () => {
968
+ const scene = new THREE.Scene()
969
+ const mat = createGlobalLegacyBlockMaterial()
970
+ const buffer = new GlobalLegacyBuffer(mat, scene)
971
+
972
+ buffer.addSection('H', quadGeometry(8000), 0, 0, 0)
973
+ buffer.addSection('B', quadGeometry(500), 0, 0, 0)
974
+ buffer.addSection('A', quadGeometry(3000), 0, 0, 0)
975
+ drainUploads(buffer)
976
+ buffer.removeSection('H')
977
+ drainUploads(buffer)
978
+ buffer.compactStep()
979
+ expect(buffer.getPendingMove()?.key).toBe('A')
980
+ const displayed = buffer.getSectionDrawStart('A')
981
+ expect(displayed).toBeDefined()
982
+
983
+ buffer.addSection('A', quadGeometry(3000), 0, 0, 0)
984
+ expect(buffer.getSectionDrawStart('A')).toBe(displayed)
985
+ expect(buffer.getPendingMove()).toBeNull()
986
+ expect(buffer.hasPendingReplace()).toBe(true)
987
+ expect(drawableLegacyIndices(buffer, 'A')).toBe(18_000)
988
+
989
+ buffer.compactStep()
990
+ buffer.uploadDirtyRange()
991
+ expect(buffer.getSectionDrawStart('A')).toBe(displayed)
992
+ expect(buffer.getPendingMove()?.key === 'A').toBe(false)
993
+ expect(drawableLegacyIndices(buffer, 'A')).toBe(18_000)
994
+
995
+ drainUploads(buffer)
996
+ buffer.compactStep()
997
+ finishCurrentMove(buffer)
998
+ expect(drawableLegacyIndices(buffer, 'A')).toBe(18_000)
999
+
1000
+ buffer.dispose()
1001
+ mat.dispose()
1002
+ })
@@ -40,6 +40,9 @@ import { FireworksManager } from './fireworks'
40
40
  import { SceneOrigin } from './sceneOrigin'
41
41
  import { downloadWorldGeometry } from './worldGeometryExport'
42
42
  import { ChunkMeshManager } from './chunkMeshManager'
43
+ import { selectReadySectionFlushes } from '../lighting/versioning/pendingSectionFlush'
44
+ import { isClientLightTraceEnabled, recordClientLightTrace } from '../lighting/debug/clientLightTrace'
45
+ import { EntityLightController } from '../lighting/entity/entityLightController'
43
46
  import { raycastVoxelSolid } from './thirdPersonVoxelRaycast'
44
47
  import type { RendererModuleManifest, RegisteredModule, RendererModuleController } from './rendererModuleSystem'
45
48
  import { BUILTIN_MODULES } from './modules/index'
@@ -89,6 +92,7 @@ export class WorldRendererThree extends WorldRendererCommon {
89
92
  ambientLight = new THREE.AmbientLight(0xcc_cc_cc, WorldRendererThree.LEGACY_TO_PHYSICAL_LIGHT)
90
93
  directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5 * WorldRendererThree.LEGACY_TO_PHYSICAL_LIGHT)
91
94
  entities = new Entities(this, (globalThis as any).mcData)
95
+ entityLightController?: EntityLightController
92
96
  performanceMonitor!: PerformanceMonitor
93
97
  cameraGroupVr?: THREE.Object3D
94
98
  material = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 })
@@ -204,6 +208,14 @@ export class WorldRendererThree extends WorldRendererCommon {
204
208
 
205
209
  // Initialize chunk mesh manager
206
210
  this.chunkMeshManager = new ChunkMeshManager(this, this.scene, this.material, this.worldSizeParams.worldHeight, this.viewDistance)
211
+ this.entityLightController = new EntityLightController({
212
+ getLight: (x, y, z) => this.rendererLightCache.getLight(x, y, z),
213
+ getSkyLevel: () => this.chunkMeshManager.getSkyLevel(),
214
+ getLightmapParams: () => this.chunkMeshManager.getLightmapParams(),
215
+ getColumnRevision: (cx, cz) => this.rendererLightCache.getColumnRevision(cx, cz),
216
+ getGlobalRevision: () => this.rendererLightCache.getGlobalRevision(),
217
+ lightingEnabled: () => this.worldRendererConfig.enableLighting !== false
218
+ })
207
219
  this.onRenderDistanceChanged = viewDistance => {
208
220
  this.chunkMeshManager.updateViewDistance(viewDistance)
209
221
  }
@@ -563,6 +575,9 @@ export class WorldRendererThree extends WorldRendererCommon {
563
575
  this.cursorBlock.setHighlightCursorBlock(value ? new Vec3(value.x, value.y, value.z) : null, value?.shapes)
564
576
  })
565
577
  this.onReactivePlayerStateUpdated('diggingBlock', value => {
578
+ if (value && (value.stage == null || value.stage === 0) && isClientLightTraceEnabled()) {
579
+ recordClientLightTrace({ phase: 'inputClick', sectionKey: `${value.x},${value.y},${value.z}` })
580
+ }
566
581
  this.cursorBlock.updateBreakAnimation(value ? { x: value.x, y: value.y, z: value.z } : undefined, value?.stage ?? null, value?.mergedShape)
567
582
  })
568
583
  this.onReactivePlayerStateUpdated('perspective', value => {
@@ -594,6 +609,13 @@ export class WorldRendererThree extends WorldRendererCommon {
594
609
  this.onReactiveConfigUpdated('futuristicReveal', () => {
595
610
  this.updateModulesFromConfig()
596
611
  })
612
+ this.onReactiveConfigUpdated(
613
+ 'enableLighting',
614
+ () => {
615
+ this.rerenderAllChunks()
616
+ },
617
+ false
618
+ )
597
619
  this.onReactiveDebugUpdated('disableEntities', () => {
598
620
  this.entities.syncSceneAttachment()
599
621
  })
@@ -830,6 +852,10 @@ export class WorldRendererThree extends WorldRendererCommon {
830
852
  if (camCollisionBytes > 0) {
831
853
  text += `CAM: ${formatCompact(camCollisionBytes)}b `
832
854
  }
855
+ const entityLightBytes = this.rendererLightCache.getAllocatedBytes()
856
+ if (entityLightBytes > 0) {
857
+ text += `ELIGHT: ${formatCompact(entityLightBytes)}b `
858
+ }
833
859
  const pf = formatPerformanceFactorsDebug(this.reactiveState.world.instabilityFactors)
834
860
  if (pf) text += `PF: ${pf} `
835
861
  // entities can be seen in F3
@@ -884,45 +910,37 @@ export class WorldRendererThree extends WorldRendererCommon {
884
910
  if (this.pendingSectionUpdates.size === 0) return
885
911
 
886
912
  const now = performance.now()
887
- const sectionHeight = this.getSectionHeight()
888
- const ready: string[] = []
889
-
890
- for (const key of this.pendingSectionUpdates.keys()) {
891
- const startedAt = this.pendingSectionBufferStartTimes.get(key) ?? now
892
- const sinceFirst = now - startedAt
893
-
894
- if (sinceFirst < WorldRendererThree.MAX_SECTION_UPDATE_BUFFER_MS) {
895
- // Still within this section's grace window — wait if any neighbor is
896
- // currently being re-meshed so we don't briefly expose a hole between
897
- // the just-updated section and a stale neighbor (sky-flicker bug).
898
- const [sx, sy, sz] = key.split(',').map(Number)
899
- const neighborKeys = [
900
- `${sx - 16},${sy},${sz}`,
901
- `${sx + 16},${sy},${sz}`,
902
- `${sx},${sy - sectionHeight},${sz}`,
903
- `${sx},${sy + sectionHeight},${sz}`,
904
- `${sx},${sy},${sz - 16}`,
905
- `${sx},${sy},${sz + 16}`
906
- ]
907
- let neighborBusy = false
908
- for (const neighborKey of neighborKeys) {
909
- if (this.sectionsWaiting.has(neighborKey) && !this.pendingSectionUpdates.has(neighborKey) && this.sectionObjects[neighborKey]) {
910
- neighborBusy = true
911
- break
913
+ // Face-adjacent buffered sections are installed as one group: a lone
914
+ // install would show the neighbour's faces still culled against the old
915
+ // block state, i.e. a see-through hole (sky flash) on dig/place.
916
+ const ready = selectReadySectionFlushes({
917
+ pendingKeys: this.pendingSectionUpdates.keys(),
918
+ startedAt: key => this.pendingSectionBufferStartTimes.get(key),
919
+ now,
920
+ maxBufferMs: WorldRendererThree.MAX_SECTION_UPDATE_BUFFER_MS,
921
+ sectionHeight: this.getSectionHeight(),
922
+ isOutstanding: key => this.sectionsWaiting.has(key) && !this.pendingSectionUpdates.has(key),
923
+ ...(this.lightCoordinator.isSessionLive()
924
+ ? {
925
+ isTopologyOutstanding: (key: string) => this.lightCoordinator.pendingTopologyBySection.has(key) && !this.pendingSectionUpdates.has(key)
912
926
  }
913
- }
914
- if (neighborBusy) continue
915
- }
916
-
917
- ready.push(key)
918
- }
927
+ : {}),
928
+ hasSectionObject: key => !!this.sectionObjects[key]
929
+ })
919
930
 
920
931
  if (ready.length === 0) return
921
932
 
922
- for (const key of ready) {
933
+ for (const { key, reason } of ready) {
923
934
  const update = this.pendingSectionUpdates.get(key)!
924
935
  this.pendingSectionUpdates.delete(key)
925
936
  this.pendingSectionBufferStartTimes.delete(key)
937
+ if (isClientLightTraceEnabled()) {
938
+ recordClientLightTrace({
939
+ phase: 'flush',
940
+ sectionKey: key,
941
+ flushReason: reason
942
+ })
943
+ }
926
944
 
927
945
  const chunkCoords = update.key.split(',')
928
946
  const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
@@ -932,6 +950,14 @@ export class WorldRendererThree extends WorldRendererCommon {
932
950
  continue
933
951
  }
934
952
 
953
+ const decision = this.lightCoordinator.evaluateOwnerGeometry(update)
954
+ if (!decision.accepted || update.geometry?.hadErrors) {
955
+ this.lightCoordinator.noteRejectedOwnerGeometry(update.key, undefined, { expectLaterFinished: false })
956
+ continue
957
+ }
958
+ this.lightCoordinator.acknowledgeAcceptedTopology(update)
959
+ if (decision.keepCovering) this.lightCoordinator.maybeDispatchCoveringRemesh(update.key)
960
+
935
961
  if (!this.chunkMeshManager.sectionHasRenderableContent(update.geometry)) {
936
962
  this.chunkMeshManager.releaseSection(update.key)
937
963
  continue
@@ -966,6 +992,14 @@ export class WorldRendererThree extends WorldRendererCommon {
966
992
  return
967
993
  }
968
994
 
995
+ const decision = this.lightCoordinator.evaluateOwnerGeometry(data)
996
+ if (!decision.accepted || data.geometry?.hadErrors) {
997
+ this.lightCoordinator.noteRejectedOwnerGeometry(data.key, undefined, { expectLaterFinished: true })
998
+ return
999
+ }
1000
+ this.lightCoordinator.acknowledgeAcceptedTopology(data)
1001
+ if (decision.keepCovering) this.lightCoordinator.maybeDispatchCoveringRemesh(data.key)
1002
+
969
1003
  if (this.sectionObjects[data.key]) {
970
1004
  this.pendingSectionUpdates.set(data.key, data)
971
1005
  // Per-section deadline: only set if we don't already have one, so
@@ -973,6 +1007,13 @@ export class WorldRendererThree extends WorldRendererCommon {
973
1007
  if (!this.pendingSectionBufferStartTimes.has(data.key)) {
974
1008
  this.pendingSectionBufferStartTimes.set(data.key, performance.now())
975
1009
  }
1010
+ if (isClientLightTraceEnabled()) {
1011
+ recordClientLightTrace({
1012
+ phase: 'pending',
1013
+ sectionKey: data.key,
1014
+ queueDepth: this.pendingSectionUpdates.size
1015
+ })
1016
+ }
976
1017
  return
977
1018
  }
978
1019
 
@@ -1552,8 +1593,8 @@ export class WorldRendererThree extends WorldRendererCommon {
1552
1593
  }
1553
1594
  }
1554
1595
 
1555
- removeColumn(x, z) {
1556
- super.removeColumn(x, z)
1596
+ removeColumn(x, z, opts?: { preserveClientLight?: boolean }) {
1597
+ super.removeColumn(x, z, opts)
1557
1598
 
1558
1599
  this.clearPendingSectionUpdatesForChunk(x, z)
1559
1600
  const sectionHeight = this.getSectionHeight()
@@ -3,6 +3,7 @@
3
3
  // This file takes WASM output and generates full Three.js buffer geometry
4
4
 
5
5
  import * as THREE from 'three'
6
+ import { isClientLightProbeBlock } from '../../lighting/debug/clientLightProbe'
6
7
  import worldBlockProviderModule, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
7
8
  import blocksAtlasesJson from 'mc-assets/dist/blocksAtlases.json'
8
9
  import blockStatesModels from 'mc-assets/dist/blockStatesModels.json'
@@ -692,7 +693,26 @@ export function renderWasmOutputToGeometry(
692
693
  },
693
694
  shaderWordBuffer
694
695
  )
695
- if (emitted) continue
696
+ if (emitted) {
697
+ if (isClientLightProbeBlock(bx, by, bz)) {
698
+ console.log('[lightProbe:shaderCube]', {
699
+ time: Date.now(),
700
+ position: [bx, by, bz],
701
+ blockStateId,
702
+ name: prismBlock.name,
703
+ useModelLighting: false,
704
+ wasmSky: block.sky_light_data?.map(values => [...values]),
705
+ visibleFaces: block.visible_faces,
706
+ jsNeighborSky: Object.fromEntries(
707
+ Object.entries(elemFaces).map(([face, info]) => [
708
+ face,
709
+ world?.getChannelLightNorm(new Vec3(bx, by, bz).offset(info.dir[0], info.dir[1], info.dir[2])).sky
710
+ ])
711
+ )
712
+ })
713
+ }
714
+ continue
715
+ }
696
716
  }
697
717
  }
698
718
 
@@ -842,6 +862,20 @@ export function renderWasmOutputToGeometry(
842
862
  const tint = getTint(matchingEFace, cachedModel.blockName, cachedModel.blockProps, biome, world)
843
863
 
844
864
  const useModelLighting = (!cachedModel.isCube || globalMatrix != null) && world
865
+ if (isClientLightProbeBlock(bx, by, bz)) {
866
+ console.log('[lightProbe:face]', {
867
+ time: Date.now(),
868
+ position: [bx, by, bz],
869
+ faceName,
870
+ blockStateId,
871
+ name: prismBlock.name,
872
+ isCube: cachedModel.isCube,
873
+ rotated: globalMatrix != null,
874
+ useModelLighting: Boolean(useModelLighting),
875
+ wasmSky: skyValuesRaw ? [...skyValuesRaw] : null,
876
+ jsNeighborSky: world?.getChannelLightNorm(new Vec3(bx, by, bz).offset(...transformedDirI)).sky
877
+ })
878
+ }
845
879
  const baseChannels = useModelLighting
846
880
  ? world.getChannelLightNorm(new Vec3(bx, by, bz).offset(transformedDirI[0], transformedDirI[1], transformedDirI[2]))
847
881
  : null
@@ -921,6 +955,17 @@ export function renderWasmOutputToGeometry(
921
955
  tgtCol.push(tintAo[0], tintAo[1], tintAo[2])
922
956
  tgtSky.push(skyLightNorm)
923
957
  tgtBlock.push(blockLightNorm)
958
+ if (isClientLightProbeBlock(bx, by, bz)) {
959
+ console.log('[lightProbe:vertex]', {
960
+ time: Date.now(),
961
+ position: [bx, by, bz],
962
+ faceName,
963
+ cornerIdx,
964
+ useModelLighting: Boolean(useModelLighting),
965
+ skyLightNorm,
966
+ blockLightNorm
967
+ })
968
+ }
924
969
 
925
970
  const baseu = (pos[3] - 0.5) * uvcs - (pos[4] - 0.5) * uvsn + 0.5
926
971
  const basev = (pos[3] - 0.5) * uvsn + (pos[4] - 0.5) * uvcs + 0.5