From 489c8cc0603a160d5aa4a2dbc72b7c005cc18a02 Mon Sep 17 00:00:00 2001 From: zverev Date: Tue, 1 Sep 2026 07:27:28 +0300 Subject: [PATCH 1/4] feat(render): tint entities from sampled block and sky light Entities now sample packed column light on the renderer thread and multiply material color, matching block lighting. Light-only 1.18+ reloads drop the fused raw map_chunk so blocks remesh from the updated column. Authored material retints (leather armor) keep their new base; distant column ingest no longer retints every entity. New Versions Lighting defaults to on. A leaked internal migration flag is stripped from saved options so it is not a user setting. Torch spread in singleplayer still needs Flying Squid to emit update_light. --- .../rendererDefaultOptions.test.ts | 55 +++ src/graphicsBackend/rendererDefaultOptions.ts | 34 +- src/graphicsBackend/rendererOptionsSync.ts | 13 +- src/lib/blockEntityLightRegistry.ts | 4 + src/lib/worldrendererCommon.ts | 11 +- src/three/chunkMeshManager.ts | 10 + src/three/entities.ts | 70 +++- src/three/entity/EntityMesh.ts | 1 + src/three/entityLightController.ts | 321 ++++++++++++++++++ src/three/rendererLightCache.ts | 197 +++++++++++ src/three/tests/entityLightController.test.ts | 215 ++++++++++++ src/three/tests/rendererLightCache.test.ts | 157 +++++++++ src/three/worldRendererThree.ts | 17 + src/wasm-mesher/worker/mesherWasm.ts | 7 +- .../worker/mesherWasmChunkBuffer.ts | 1 + .../worker/mesherWasmLightDirty.test.ts | 15 +- .../worker/mesherWasmLightDirty.ts | 14 + 17 files changed, 1132 insertions(+), 10 deletions(-) create mode 100644 src/graphicsBackend/rendererDefaultOptions.test.ts create mode 100644 src/three/entityLightController.ts create mode 100644 src/three/rendererLightCache.ts create mode 100644 src/three/tests/entityLightController.test.ts create mode 100644 src/three/tests/rendererLightCache.test.ts diff --git a/src/graphicsBackend/rendererDefaultOptions.test.ts b/src/graphicsBackend/rendererDefaultOptions.test.ts new file mode 100644 index 0000000..da7a7b8 --- /dev/null +++ b/src/graphicsBackend/rendererDefaultOptions.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { + migrateRendererOptions, + resolveEnableLighting, + upgradeStoredNewVersionsLightingDefault +} from './rendererDefaultOptions' + +describe('migrateRendererOptions', () => { + it('drops the leaked migration flag so it is not a user option', () => { + const saved: Record = { + newVersionsLighting: false, + migratedNewVersionsLightingDefault: true + } + migrateRendererOptions(saved) + expect(saved.newVersionsLighting).toBe(false) + expect(saved).not.toHaveProperty('migratedNewVersionsLightingDefault') + }) +}) + +describe('upgradeStoredNewVersionsLightingDefault', () => { + it('drops a stored false from the old default on first upgrade', () => { + const saved: Record = { newVersionsLighting: false } + expect(upgradeStoredNewVersionsLightingDefault(saved, false)).toBe(true) + expect(saved).not.toHaveProperty('newVersionsLighting') + }) + + it('keeps an explicit false after the upgrade has already run', () => { + const saved: Record = { newVersionsLighting: false } + expect(upgradeStoredNewVersionsLightingDefault(saved, true)).toBe(true) + expect(saved.newVersionsLighting).toBe(false) + }) + + it('does not invent a stored value when the key was never saved', () => { + const saved: Record = {} + expect(upgradeStoredNewVersionsLightingDefault(saved, false)).toBe(true) + expect(saved).not.toHaveProperty('newVersionsLighting') + }) +}) + +describe('resolveEnableLighting', () => { + it('follows the option on 1.13+ (blockStateId)', () => { + expect(resolveEnableLighting(true, true)).toBe(true) + expect(resolveEnableLighting(false, true)).toBe(false) + }) + + it('always enables lighting on pre-1.13', () => { + expect(resolveEnableLighting(false, false)).toBe(true) + expect(resolveEnableLighting(true, false)).toBe(true) + }) + + it('treats a missing protocol probe as pre-1.13 so a menu toggle cannot throw', () => { + expect(resolveEnableLighting(false, undefined)).toBe(true) + expect(resolveEnableLighting(true, undefined)).toBe(true) + }) +}) diff --git a/src/graphicsBackend/rendererDefaultOptions.ts b/src/graphicsBackend/rendererDefaultOptions.ts index 87061fb..9af390a 100644 --- a/src/graphicsBackend/rendererDefaultOptions.ts +++ b/src/graphicsBackend/rendererDefaultOptions.ts @@ -65,7 +65,7 @@ export const RENDERER_DEFAULT_OPTIONS = { backgroundRendering: '20fps' as 'full' | '20fps' | '5fps', vanillaLook: false as boolean, smoothLighting: true as boolean, - newVersionsLighting: false as boolean, + newVersionsLighting: true as boolean, vrSupport: true as boolean, vrPageGameRendering: false as boolean, clipWorldBelowY: undefined as number | undefined, @@ -121,6 +121,35 @@ export function migrateRendererOptions(saved: Record): void { } delete saved[oldKey] } + + // Internal one-shot flag from an earlier lighting-default migration. Never a user option. + delete saved.migratedNewVersionsLightingDefault +} + +/** + * One-shot upgrade of the old default (`false`) to the current default (`true`). + * `alreadyMigrated` must live outside user-visible options (e.g. a dedicated localStorage key). + * Returns true so the caller can persist that the upgrade has run. + */ +export function upgradeStoredNewVersionsLightingDefault( + saved: Record, + alreadyMigrated: boolean +): boolean { + if (!alreadyMigrated && saved.newVersionsLighting === false) { + delete saved.newVersionsLighting + } + return true +} + +/** + * Runtime `enableLighting` from the stored option and protocol. + * Pre-1.13 (no blockStateId) always lights. Missing protocol (menu, no bot) must not throw. + */ +export function resolveEnableLighting( + newVersionsLighting: boolean, + blockStateIdSupported: boolean | undefined +): boolean { + return blockStateIdSupported !== true || newVersionsLighting } /** Settings UI metadata for {@link RENDERER_DEFAULT_OPTIONS} keys. */ @@ -235,7 +264,8 @@ export const RENDERER_OPTIONS_META: Partial( } /** Call when mineflayer bot is created (lighting depends on protocol features). */ -export function applyRendererEnableLighting(appViewer: AppViewer, newVersionsLighting: boolean, blockStateIdSupported: boolean): void { - appViewer.inWorldRenderingConfig.enableLighting = !blockStateIdSupported || newVersionsLighting +export function applyRendererEnableLighting( + appViewer: AppViewer, + newVersionsLighting: boolean, + blockStateIdSupported: boolean | undefined +): void { + appViewer.inWorldRenderingConfig.enableLighting = resolveEnableLighting( + newVersionsLighting, + blockStateIdSupported + ) } diff --git a/src/lib/blockEntityLightRegistry.ts b/src/lib/blockEntityLightRegistry.ts index adc5d60..76fac4a 100644 --- a/src/lib/blockEntityLightRegistry.ts +++ b/src/lib/blockEntityLightRegistry.ts @@ -40,6 +40,10 @@ export class BlockEntityLightRegistry { return this.skyLevel } + getLightmapParams(): BlockLightmapParams { + return { ...this.lightmapParams } + } + private refreshAll(): void { for (const entry of this.entries) { this.applyBrightness(entry) diff --git a/src/lib/worldrendererCommon.ts b/src/lib/worldrendererCommon.ts index 4dc1fac..b9e5b22 100644 --- a/src/lib/worldrendererCommon.ts +++ b/src/lib/worldrendererCommon.ts @@ -32,6 +32,7 @@ import { PlayerStateReactive } from '../playerState/playerState' import { IndexedData } from 'minecraft-data' import { WorldRendererConfig } from '../graphicsBackend/config' import { CameraCollisionBlockCache } from '../three/cameraCollisionBlockCache' +import { RendererLightCache } from '../three/rendererLightCache' function mod(x, n) { return ((x % n) + n) % n @@ -45,6 +46,8 @@ export abstract class WorldRendererCommon worldSizeParams = { minY: 0, worldHeight: 256 } /** Block columns for third-person voxel DDA (renderer thread). */ cameraCollisionBlockCache: CameraCollisionBlockCache + /** Packed block/sky light for entity sampling (renderer thread). */ + rendererLightCache: RendererLightCache reactiveDebugParams = proxy({ stopRendering: false, chunksRenderAboveOverride: undefined as number | undefined, @@ -222,6 +225,7 @@ export abstract class WorldRendererCommon public initOptions: GraphicsInitOptions ) { this.cameraCollisionBlockCache = new CameraCollisionBlockCache(this.displayOptions.version) + this.rendererLightCache = new RendererLightCache(this.displayOptions.version) this.snapshotInitialValues() this.worldRendererConfig = displayOptions.inWorldRenderingConfig this.playerStateReactive = displayOptions.playerStateReactive! @@ -885,7 +889,8 @@ export abstract class WorldRendererCommon x, z, chunk, - customBlockModels: customBlockModels || undefined + customBlockModels: customBlockModels || undefined, + isLightUpdate }) } // WASM mesher pushes heightmaps from `processColumnTick` after each @@ -906,6 +911,7 @@ export abstract class WorldRendererCommon ) this.mesherLogReader?.chunkReceived(x, z, chunk.length) this.cameraCollisionBlockCache.ingestColumn(x, z, chunk) + this.rendererLightCache.ingestColumn(x, z, chunk) const sectionHeight = this.getSectionHeight() const CHUNK_SIZE = 16 @@ -975,6 +981,7 @@ export abstract class WorldRendererCommon } this.highestBlocksByChunks.delete(`${x},${z}`) this.cameraCollisionBlockCache.removeColumn(x, z) + this.rendererLightCache.removeColumn(x, z) const heightmapKey = `${Math.floor(x / 16)},${Math.floor(z / 16)}` delete this.reactiveState.world.heightmaps[heightmapKey] @@ -1052,6 +1059,7 @@ export abstract class WorldRendererCommon ({ x, z, chunk, worldConfig, isLightUpdate }) => { this.worldSizeParams = worldConfig this.cameraCollisionBlockCache.setWorldBounds(worldConfig.minY, worldConfig.worldHeight) + this.rendererLightCache.setWorldBounds(worldConfig.minY, worldConfig.worldHeight) this.queuedChunks.add(`${x},${z}`) const args = [x, z, chunk, isLightUpdate] if (!currentLoadChunkBatch) { @@ -1156,6 +1164,7 @@ export abstract class WorldRendererCommon 'onWorldSwitch', () => { this.cameraCollisionBlockCache.clear() + this.rendererLightCache.clear() for (const fn of this.onWorldSwitched) { try { fn() diff --git a/src/three/chunkMeshManager.ts b/src/three/chunkMeshManager.ts index c8755e9..01771d5 100644 --- a/src/three/chunkMeshManager.ts +++ b/src/three/chunkMeshManager.ts @@ -284,6 +284,15 @@ export class ChunkMeshManager { if (this.globalLegacyShaderMaterial) setLegacySkyLevel(this.globalLegacyShaderMaterial, value) if (this.globalLegacyBlendShaderMaterial) setLegacySkyLevel(this.globalLegacyBlendShaderMaterial, value) this.blockEntityLightRegistry.setSkyLevel(value) + this.worldRenderer.entityLightController?.setSkyLevel(value) + } + + getSkyLevel(): number { + return this.blockEntityLightRegistry.getSkyLevel() + } + + getLightmapParams(): BlockLightmapParams { + return this.blockEntityLightRegistry.getLightmapParams() } setShadingTheme(theme: 'vanilla' | 'high-contrast', cardinalLight: string): void { @@ -299,6 +308,7 @@ export class ChunkMeshManager { if (this.globalLegacyShaderMaterial) setLegacyLightmapParams(this.globalLegacyShaderMaterial, params) if (this.globalLegacyBlendShaderMaterial) setLegacyLightmapParams(this.globalLegacyBlendShaderMaterial, params) this.blockEntityLightRegistry.setLightmapParams(params) + this.worldRenderer.entityLightController?.setLightmapParams(params) } private getLegacyShaderMaterial(): THREE.ShaderMaterial { diff --git a/src/three/entities.ts b/src/three/entities.ts index ab9924c..1cc4085 100644 --- a/src/three/entities.ts +++ b/src/three/entities.ts @@ -61,6 +61,7 @@ import { WalkingGeneralSwing } from './entity/animations' import { disposeObject, loadNearestFilterTexture, loadTexture, loadThreeJsTextureFromUrl } from './threeJsUtils' import { armorModel, armorTextures, elytraTexture } from './entity/armorModels' import { WorldRendererThree } from './worldRendererThree' +import type { EntityLightMeta } from './entityLightController' import { IndexedData } from 'minecraft-data' import { ItemSpecificContextProperties } from '../playerState/types' @@ -385,9 +386,12 @@ export class Entities { } this.updateEntityEquipment(this.playerEntity, playerData) + this.registerEntityLight(this.playerEntity, this.playerEntity.originalEntity) + this.invalidateEntityLight(this.playerEntity) } clear() { + this.worldRenderer.entityLightController?.clear() for (const mesh of Object.values(this.entities)) { this.worldRenderer.sceneOrigin.removeAndUntrack(mesh) disposeObject(mesh) @@ -557,6 +561,7 @@ export class Entities { this.applyRemoteBoatPassengerRotations() this.applyLocalThirdPersonPlayerRotation() this.updateBoatPaddleAnimations(dt) + this.applyEntityLights() } private applyRemoteBoatPassengerRotations() { @@ -1082,6 +1087,7 @@ export class Entities { if (c['additionalCleanup']) c['additionalCleanup']() }) this.onRemoveEntity(entity) + this.worldRenderer.entityLightController?.unregister(e) this.worldRenderer.sceneOrigin.removeAndUntrack(e) disposeObject(e) // todo dispose textures as well ? @@ -1403,6 +1409,12 @@ export class Entities { this.updateNameTagVisibility(e) this.updateEntityPosition(entity, justAdded, overrides) + if (justAdded) { + this.registerEntityLight(e, entity) + } else { + this.worldRenderer.entityLightController?.setMeta(e, this.entityLightMeta(entity)) + this.invalidateEntityLight(e) + } } applyEntityRenderHints(e: SceneEntity, entity: SceneEntity['originalEntity']) { @@ -1699,8 +1711,52 @@ export class Entities { } } + onEntityMeshReady(mesh: THREE.Object3D) { + this.worldRenderer.entityLightController?.invalidateFromDescendant(mesh) + } + + private entityLightSharedMaterials() { + return new Set([this.worldRenderer.material]) + } + + private entityLightMeta(entity: SceneEntity['originalEntity']): EntityLightMeta { + const localPlayerId = this.playerEntity?.originalEntity.id + const useLocalEyeHeight = entity.name === 'player' && localPlayerId !== undefined && entity.id === localPlayerId + return { + name: entity.name, + height: entity.height, + eyeHeight: useLocalEyeHeight ? this.worldRenderer.playerStateReactive.eyeHeight : undefined + } + } + + private registerEntityLight(root: SceneEntity, entity: SceneEntity['originalEntity']) { + this.worldRenderer.entityLightController?.register(root, this.entityLightMeta(entity), this.entityLightSharedMaterials()) + } + + private invalidateEntityLight(root: THREE.Object3D) { + this.worldRenderer.entityLightController?.invalidateMaterials(root) + } + + private applyEntityLights() { + const controller = this.worldRenderer.entityLightController + if (!controller) return + const shared = this.entityLightSharedMaterials() + const apply = (root: SceneEntity | null) => { + if (!root) return + if (root === this.playerEntity) { + controller.setMeta(root, this.entityLightMeta(root.originalEntity)) + } + const worldPos = this.worldRenderer.sceneOrigin.getWorldPosition(root) + if (!worldPos) return + controller.update(root, worldPos, shared) + } + for (const entity of Object.values(this.entities)) apply(entity) + apply(this.playerEntity) + } + handleDamageEvent(entityId, damageAmount) { - const entityMesh = this.entities[entityId]?.children.find(c => c.name === 'mesh') + const entity = this.entities[entityId] + const entityMesh = entity?.children.find(c => c.name === 'mesh') if (entityMesh) { entityMesh.traverse(child => { if (child instanceof THREE.Mesh && child.material.clone) { @@ -1713,6 +1769,18 @@ export class Entities { } }) } + if (entity) { + this.worldRenderer.entityLightController?.markFlash(entity) + this.invalidateEntityLight(entity) + setTimeout(() => { + if (!this.entities[entityId]) return + this.worldRenderer.entityLightController?.endFlash(entity) + const worldPos = this.worldRenderer.sceneOrigin.getWorldPosition(entity) + if (worldPos) { + this.worldRenderer.entityLightController?.update(entity, worldPos, this.entityLightSharedMaterials()) + } + }, 500) + } } raycastSceneDebug() { diff --git a/src/three/entity/EntityMesh.ts b/src/three/entity/EntityMesh.ts index 0e62723..b330bfa 100644 --- a/src/three/entity/EntityMesh.ts +++ b/src/three/entity/EntityMesh.ts @@ -535,6 +535,7 @@ export class EntityMesh { controller.playAnimation(gltf.animations[0].name, loop) } } + worldRenderer?.entities.onEntityMeshReady(this.mesh) }) .catch(err => { console.error('Failed to load GLTF model:', err) diff --git a/src/three/entityLightController.ts b/src/three/entityLightController.ts new file mode 100644 index 0000000..ab58f84 --- /dev/null +++ b/src/three/entityLightController.ts @@ -0,0 +1,321 @@ +import * as THREE from 'three' +import { blockEntityBrightness, DEFAULT_LIGHTMAP_PARAMS, type BlockLightmapParams } from '../lib/blockEntityLighting' + +export type EntityLightOverride = 'normal' | 'fullbright' + +export type EntityLightWorldPos = { x: number; y: number; z: number } + +export type EntityLightMeta = { + name?: string + height?: number + eyeHeight?: number +} + +export type EntityLightSampler = { + getLight: (x: number, y: number, z: number) => { block: number; sky: number } + getSkyLevel: () => number + getLightmapParams: () => BlockLightmapParams + getColumnRevision: (chunkX: number, chunkZ: number) => number | undefined + getGlobalRevision: () => number + lightingEnabled: () => boolean +} + +const BASE_COLOR_KEY = 'entityLightBaseColor' +const LAST_APPLIED_COLOR_KEY = 'entityLightLastAppliedColor' +const COLOR_EPSILON = 1e-4 +const LIVING_EYE_HEIGHT_RATIO = 0.85 + +type ColorBearingMaterial = THREE.Material & { color: THREE.Color } + +type ControlledMaterial = { + material: ColorBearingMaterial + base: THREE.Color +} + +type TrackedRoot = { + meta: EntityLightMeta + override: EntityLightOverride + materials: ControlledMaterial[] + materialsDirty: boolean + flashing: boolean + lastProbe?: { x: number; y: number; z: number } + lastColumnRevision?: number + lastGlobalRevision?: number + lastSkyLevel?: number + lastLightmapParams?: BlockLightmapParams + lastLightingEnabled?: boolean + lastBrightness?: number +} + +const alignColumn = (coord: number) => Math.floor(coord / 16) * 16 + +export function resolveEntityEyeHeight(meta: EntityLightMeta = {}): number { + if (Number.isFinite(meta.eyeHeight)) return meta.eyeHeight as number + if (Number.isFinite(meta.height)) return (meta.height as number) * LIVING_EYE_HEIGHT_RATIO + return 0 +} + +export function entityProbeBlock(worldPos: EntityLightWorldPos, eyeHeight = 0): { x: number; y: number; z: number } { + return { + x: Math.floor(worldPos.x), + y: Math.floor(worldPos.y + eyeHeight), + z: Math.floor(worldPos.z) + } +} + +export function resolveEntityLightOverride(name?: string): EntityLightOverride { + return name === 'glow_item_frame' ? 'fullbright' : 'normal' +} + +type Rgb = { r: number; g: number; b: number } + +const hasColor = (material: THREE.Material): material is ColorBearingMaterial => !!material && (material as ColorBearingMaterial).color instanceof THREE.Color + +const readRgb = (material: ColorBearingMaterial, key: string): THREE.Color | undefined => { + const stored = material.userData?.[key] as Rgb | undefined + if (!stored || !Number.isFinite(stored.r) || !Number.isFinite(stored.g) || !Number.isFinite(stored.b)) return undefined + return new THREE.Color(stored.r, stored.g, stored.b) +} + +const storeRgb = (material: ColorBearingMaterial, key: string, color: THREE.Color) => { + material.userData[key] = { r: color.r, g: color.g, b: color.b } +} + +const colorsClose = (a: THREE.Color, b: THREE.Color) => + Math.abs(a.r - b.r) < COLOR_EPSILON && Math.abs(a.g - b.g) < COLOR_EPSILON && Math.abs(a.b - b.b) < COLOR_EPSILON + +const readStoredBase = (material: ColorBearingMaterial): THREE.Color | undefined => readRgb(material, BASE_COLOR_KEY) + +const storeBase = (material: ColorBearingMaterial, base: THREE.Color) => { + storeRgb(material, BASE_COLOR_KEY, base) +} + +const readLastApplied = (material: ColorBearingMaterial): THREE.Color | undefined => readRgb(material, LAST_APPLIED_COLOR_KEY) + +const storeLastApplied = (material: ColorBearingMaterial, color: THREE.Color) => { + storeRgb(material, LAST_APPLIED_COLOR_KEY, color) +} + +const resolveBaseColor = (material: ColorBearingMaterial, flashing: boolean): THREE.Color => { + const stored = readStoredBase(material) + if (!stored) { + const base = material.color.clone() + storeBase(material, base) + return base + } + if (flashing) return stored + const lastApplied = readLastApplied(material) + if (lastApplied && colorsClose(material.color, lastApplied)) return stored + const base = material.color.clone() + storeBase(material, base) + return base +} + +const applyBrightnessToMaterial = (entry: ControlledMaterial, brightness: number) => { + entry.material.color.setRGB(entry.base.r * brightness, entry.base.g * brightness, entry.base.b * brightness) + storeLastApplied(entry.material, entry.material.color) +} + +const lightmapParamsEqual = (a?: BlockLightmapParams, b?: BlockLightmapParams) => + a === b || + (!!a && + !!b && + (a.curve ?? DEFAULT_LIGHTMAP_PARAMS.curve) === (b.curve ?? DEFAULT_LIGHTMAP_PARAMS.curve) && + (a.minBrightness ?? DEFAULT_LIGHTMAP_PARAMS.minBrightness) === (b.minBrightness ?? DEFAULT_LIGHTMAP_PARAMS.minBrightness) && + (a.gamma ?? DEFAULT_LIGHTMAP_PARAMS.gamma) === (b.gamma ?? DEFAULT_LIGHTMAP_PARAMS.gamma)) + +export class EntityLightController { + private readonly roots = new Map() + private skyLevel = 1 + private lightmapParams: BlockLightmapParams = { ...DEFAULT_LIGHTMAP_PARAMS } + + constructor(private readonly sampler: EntityLightSampler) { + this.skyLevel = sampler.getSkyLevel() + this.lightmapParams = { ...DEFAULT_LIGHTMAP_PARAMS, ...sampler.getLightmapParams() } + } + + setSkyLevel(value: number) { + this.skyLevel = value + this.refreshAll() + } + + setLightmapParams(params: BlockLightmapParams) { + this.lightmapParams = { ...this.lightmapParams, ...params } + this.refreshAll() + } + + register(root: THREE.Object3D, meta: EntityLightMeta = {}, sharedMaterials?: Iterable) { + const shared = sharedMaterials ? new Set(sharedMaterials) : undefined + const existing = this.roots.get(root) + if (existing) { + existing.meta = { ...existing.meta, ...meta } + existing.override = resolveEntityLightOverride(existing.meta.name) + existing.materialsDirty = true + existing.lastProbe = undefined + if (shared) this.discoverMaterials(root, existing, shared) + return + } + const tracked: TrackedRoot = { + meta, + override: resolveEntityLightOverride(meta.name), + materials: [], + materialsDirty: true, + flashing: false + } + this.roots.set(root, tracked) + if (shared) this.discoverMaterials(root, tracked, shared) + } + + setMeta(root: THREE.Object3D, meta: EntityLightMeta) { + const tracked = this.roots.get(root) + if (!tracked) return + tracked.meta = { ...tracked.meta, ...meta } + tracked.override = resolveEntityLightOverride(tracked.meta.name) + tracked.lastProbe = undefined + } + + unregister(root: THREE.Object3D) { + this.roots.delete(root) + } + + clear() { + this.roots.clear() + } + + invalidateMaterials(root: THREE.Object3D) { + const tracked = this.roots.get(root) + if (!tracked) return + tracked.materialsDirty = true + tracked.lastProbe = undefined + } + + invalidateFromDescendant(node: THREE.Object3D) { + let current: THREE.Object3D | null = node + while (current) { + if (this.roots.has(current)) { + this.invalidateMaterials(current) + return + } + current = current.parent + } + } + + markFlash(root: THREE.Object3D, _durationMs?: number) { + const tracked = this.roots.get(root) + if (!tracked) return + tracked.flashing = true + } + + endFlash(root: THREE.Object3D) { + const tracked = this.roots.get(root) + if (!tracked) return + tracked.flashing = false + tracked.lastProbe = undefined + } + + update(root: THREE.Object3D, worldPos: EntityLightWorldPos, sharedMaterials?: Iterable) { + const tracked = this.roots.get(root) + if (!tracked) return + const materialsWereDirty = tracked.materialsDirty + if (tracked.materialsDirty) { + this.discoverMaterials(root, tracked, sharedMaterials ? new Set(sharedMaterials) : undefined) + } + + const lightingEnabled = this.sampler.lightingEnabled() + const probe = entityProbeBlock(worldPos, resolveEntityEyeHeight(tracked.meta)) + const columnRevision = this.sampler.getColumnRevision(alignColumn(probe.x), alignColumn(probe.z)) + const globalRevision = this.sampler.getGlobalRevision() + const skyLevel = this.skyLevel + const lightmapParams = this.lightmapParams + + const unchanged = + tracked.lastProbe && + tracked.lastProbe.x === probe.x && + tracked.lastProbe.y === probe.y && + tracked.lastProbe.z === probe.z && + tracked.lastColumnRevision === columnRevision && + tracked.lastGlobalRevision === globalRevision && + tracked.lastSkyLevel === skyLevel && + tracked.lastLightingEnabled === lightingEnabled && + lightmapParamsEqual(tracked.lastLightmapParams, lightmapParams) + + if (unchanged && !materialsWereDirty) return + + tracked.lastProbe = probe + tracked.lastColumnRevision = columnRevision + tracked.lastGlobalRevision = globalRevision + tracked.lastSkyLevel = skyLevel + tracked.lastLightmapParams = lightmapParams + tracked.lastLightingEnabled = lightingEnabled + + if (tracked.flashing) return + + const brightness = this.computeBrightness(tracked, probe, lightingEnabled) + tracked.lastBrightness = brightness + for (const entry of tracked.materials) { + applyBrightnessToMaterial(entry, brightness) + } + } + + private refreshAll() { + for (const [root, tracked] of this.roots) { + if (tracked.flashing || tracked.materialsDirty) { + tracked.lastProbe = undefined + continue + } + const lightingEnabled = this.sampler.lightingEnabled() + const probe = tracked.lastProbe ?? { x: 0, y: 0, z: 0 } + const brightness = tracked.lastProbe ? this.computeBrightness(tracked, probe, lightingEnabled) : undefined + if (brightness === undefined) { + tracked.lastProbe = undefined + continue + } + tracked.lastSkyLevel = this.skyLevel + tracked.lastLightmapParams = this.lightmapParams + tracked.lastLightingEnabled = lightingEnabled + tracked.lastBrightness = brightness + for (const entry of tracked.materials) { + applyBrightnessToMaterial(entry, brightness) + } + void root + } + } + + private computeBrightness(tracked: TrackedRoot, probe: { x: number; y: number; z: number }, lightingEnabled: boolean) { + if (!lightingEnabled || tracked.override === 'fullbright') return 1 + const sample = this.sampler.getLight(probe.x, probe.y, probe.z) + return blockEntityBrightness(sample.block, sample.sky, this.skyLevel, this.lightmapParams) + } + + private discoverMaterials(root: THREE.Object3D, tracked: TrackedRoot, sharedMaterials?: Set) { + const next: ControlledMaterial[] = [] + const seen = new Set() + root.traverse(child => { + if (child.name === 'debug') return + const raw = (child as THREE.Mesh).material + if (!raw) return + const list = Array.isArray(raw) ? raw : [raw] + let replacedArray = false + const nextList = list.map((material, index) => { + if (!hasColor(material) || seen.has(material)) return material + seen.add(material) + let current = material + if (sharedMaterials?.has(current)) { + current = current.clone() as ColorBearingMaterial + if (Array.isArray(raw)) { + list[index] = current + replacedArray = true + } else { + ;(child as THREE.Mesh).material = current + } + } + const base = resolveBaseColor(current, tracked.flashing) + next.push({ material: current, base }) + return current + }) + if (replacedArray) (child as THREE.Mesh).material = nextList + }) + tracked.materials = next + tracked.materialsDirty = false + } +} diff --git a/src/three/rendererLightCache.ts b/src/three/rendererLightCache.ts new file mode 100644 index 0000000..468694a --- /dev/null +++ b/src/three/rendererLightCache.ts @@ -0,0 +1,197 @@ +import Chunks from 'prismarine-chunk' +import { Vec3 } from 'vec3' + +/** 16³ blocks, one packed byte (block<<4 | sky) per block. */ +export const RENDERER_LIGHT_BYTES_PER_SECTION = 4096 + +const FULLBRIGHT = { block: 0, sky: 1 } as const + +const sectionKey = (sx: number, sy: number, sz: number) => `${sx},${sy},${sz}` +const columnKey = (sx: number, sz: number) => `${sx},${sz}` +const alignSection = (coord: number) => Math.floor(coord / 16) * 16 +const localCoord = (coord: number) => ((coord % 16) + 16) % 16 +const blockIndex = (lx: number, ly: number, lz: number) => lx + lz * 16 + ly * 256 + +const channelNorm = (raw: number) => Math.min(15, raw + 2) / 15 + +const getLightSectionIndex = (y: number, minY = 0) => Math.floor((y - minY) / 16) + 1 + +const hasChunkSection = (column: any, pos: Vec3) => { + if (column._getSection) return column._getSection(pos) + if (column.skyLightSections) { + return column.skyLightSections[getLightSectionIndex(pos.y, column.minY)] || column.blockLightSections[getLightSectionIndex(pos.y, column.minY)] + } + if (column.sections) return column.sections[pos.y >> 4] + return false +} + +const hashBytes = (sections: Map) => { + let hash = 2166136261 + const keys = [...sections.keys()].sort() + for (const key of keys) { + const data = sections.get(key)! + for (let i = 0; i < key.length; i++) { + hash ^= key.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + for (let i = 0; i < data.length; i++) { + hash ^= data[i]! + hash = Math.imul(hash, 16777619) + } + } + return hash >>> 0 +} + +type LightChunk = { + getBlockLight: (pos: Vec3) => number + getSkyLight: (pos: Vec3) => number + minY?: number + _getSection?: (pos: Vec3) => unknown + skyLightSections?: unknown[] + blockLightSections?: unknown[] + sections?: unknown +} + +export type RendererLightSample = { block: number; sky: number } + +export type RendererLightIngestResult = { changed: boolean; revision: number } + +/** + * Main-thread packed block/sky light for entity sampling. + * Absent sections stay fullbright; present raw-zero gets the project +2 floor. + */ +export class RendererLightCache { + private readonly sections = new Map() + private readonly columnHashes = new Map() + private readonly columnRevisions = new Map() + private Chunk: ReturnType + private worldMinY = 0 + private worldMaxY = 256 + private globalRevision = 0 + private nextColumnRevision = 1 + + constructor(version: string) { + this.Chunk = Chunks(version) as ReturnType + } + + setVersion(version: string) { + this.Chunk = Chunks(version) as ReturnType + this.clear() + } + + setWorldBounds(minY: number, worldHeight: number) { + if (this.worldMinY === minY && this.worldMaxY === worldHeight) return + this.worldMinY = minY + this.worldMaxY = worldHeight + this.clear() + } + + clear() { + if (this.sections.size === 0 && this.columnRevisions.size === 0) { + this.globalRevision++ + return + } + this.sections.clear() + this.columnHashes.clear() + this.columnRevisions.clear() + this.globalRevision++ + } + + getAllocatedSectionCount(): number { + return this.sections.size + } + + getAllocatedBytes(): number { + return this.sections.size * RENDERER_LIGHT_BYTES_PER_SECTION + } + + getGlobalRevision(): number { + return this.globalRevision + } + + getColumnRevision(chunkX: number, chunkZ: number): number | undefined { + return this.columnRevisions.get(columnKey(alignSection(chunkX), alignSection(chunkZ))) + } + + removeColumn(chunkX: number, chunkZ: number) { + const sx = alignSection(chunkX) + const sz = alignSection(chunkZ) + const col = columnKey(sx, sz) + const syStart = alignSection(this.worldMinY) + for (let sy = syStart; sy < this.worldMaxY; sy += 16) { + this.sections.delete(sectionKey(sx, sy, sz)) + } + this.columnHashes.delete(col) + this.columnRevisions.delete(col) + } + + ingestColumn(chunkX: number, chunkZ: number, chunkJson: unknown): RendererLightIngestResult { + const sx = alignSection(chunkX) + const sz = alignSection(chunkZ) + const col = columnKey(sx, sz) + const previousRevision = this.columnRevisions.get(col) ?? 0 + + let chunk: LightChunk + try { + chunk = this.Chunk.fromJson(chunkJson as Parameters['fromJson']>[0]) as unknown as LightChunk + if (typeof chunk?.getBlockLight !== 'function' || typeof chunk?.getSkyLight !== 'function') { + throw new Error('invalid chunk light API') + } + } catch { + return { changed: false, revision: previousRevision } + } + + const packed = new Map() + const pos = new Vec3(0, 0, 0) + const syStart = alignSection(this.worldMinY) + for (let sy = syStart; sy < this.worldMaxY; sy += 16) { + pos.set(0, sy, 0) + if (!hasChunkSection(chunk, pos)) continue + const data = new Uint8Array(RENDERER_LIGHT_BYTES_PER_SECTION) + for (let ly = 0; ly < 16; ly++) { + pos.y = sy + ly + for (let lz = 0; lz < 16; lz++) { + pos.z = lz + for (let lx = 0; lx < 16; lx++) { + pos.x = lx + const block = Math.max(0, Math.min(15, chunk.getBlockLight(pos) | 0)) + const sky = Math.max(0, Math.min(15, chunk.getSkyLight(pos) | 0)) + data[blockIndex(lx, ly, lz)] = (block << 4) | sky + } + } + } + packed.set(sectionKey(sx, sy, sz), data) + } + + const hash = hashBytes(packed) + if (this.columnHashes.get(col) === hash && this.columnRevisions.has(col)) { + return { changed: false, revision: previousRevision } + } + + const syStartRemove = alignSection(this.worldMinY) + for (let sy = syStartRemove; sy < this.worldMaxY; sy += 16) { + this.sections.delete(sectionKey(sx, sy, sz)) + } + for (const [key, data] of packed) { + this.sections.set(key, data) + } + this.columnHashes.set(col, hash) + const revision = this.nextColumnRevision++ + this.columnRevisions.set(col, revision) + return { changed: true, revision } + } + + getLight(x: number, y: number, z: number): RendererLightSample { + if (y < this.worldMinY || y >= this.worldMaxY) return { ...FULLBRIGHT } + const sx = alignSection(x) + const sy = alignSection(y) + const sz = alignSection(z) + const data = this.sections.get(sectionKey(sx, sy, sz)) + if (!data) return { ...FULLBRIGHT } + const packed = data[blockIndex(localCoord(x), y - sy, localCoord(z))] ?? 0 + return { + block: channelNorm(packed >> 4), + sky: channelNorm(packed & 0x0f) + } + } +} diff --git a/src/three/tests/entityLightController.test.ts b/src/three/tests/entityLightController.test.ts new file mode 100644 index 0000000..fb8b1dc --- /dev/null +++ b/src/three/tests/entityLightController.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import * as THREE from 'three' +import { blockEntityBrightness, DEFAULT_LIGHTMAP_PARAMS } from '../../lib/blockEntityLighting' +import { EntityLightController, entityProbeBlock, resolveEntityLightOverride } from '../entityLightController' + +const nightSky = 4 / 15 + +function makeController( + getLight: (x: number, y: number, z: number) => { block: number; sky: number } = () => ({ block: 0, sky: 1 }), + extras: Partial[0]> = {} +) { + let skyLevel = 1 + let lightingEnabled = true + let globalRevision = 1 + const columnRevisions = new Map([['0,0', 1]]) + const controller = new EntityLightController({ + getLight, + getSkyLevel: () => skyLevel, + getLightmapParams: () => DEFAULT_LIGHTMAP_PARAMS, + getColumnRevision: (cx, cz) => columnRevisions.get(`${cx},${cz}`), + getGlobalRevision: () => globalRevision, + lightingEnabled: () => lightingEnabled, + ...extras + }) + return { + controller, + setSkyLevel: (value: number) => { + skyLevel = value + controller.setSkyLevel(value) + }, + setLightingEnabled: (value: boolean) => { + lightingEnabled = value + }, + bumpColumn: () => { + columnRevisions.set('0,0', (columnRevisions.get('0,0') ?? 0) + 1) + }, + bumpFarColumn: () => { + columnRevisions.set('16,0', (columnRevisions.get('16,0') ?? 0) + 1) + } + } +} + +describe('entityProbeBlock', () => { + it('samples the eye block, not the block above the AABB', () => { + expect(entityProbeBlock({ x: 8.2, y: 64.3, z: 8.7 }, 1.62)).toEqual({ x: 8, y: 65, z: 8 }) + expect(entityProbeBlock({ x: 8.2, y: 64.3, z: 8.7 }, 1.8)).toEqual({ x: 8, y: 66, z: 8 }) + }) +}) + +describe('resolveEntityLightOverride', () => { + it('fullbrights only reliably identified glow item frames', () => { + expect(resolveEntityLightOverride('glow_item_frame')).toBe('fullbright') + expect(resolveEntityLightOverride('item_frame')).toBe('normal') + expect(resolveEntityLightOverride('blaze')).toBe('normal') + }) +}) + +describe('EntityLightController', () => { + it('multiplies immutable base tint and is idempotent', () => { + const { controller } = makeController(() => ({ block: 0, sky: 1 })) + const material = new THREE.MeshLambertMaterial({ color: 0xb5_6d_51 }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), material) + const root = new THREE.Group() + root.add(mesh) + const base = material.color.clone() + controller.register(root, { eyeHeight: 1.62 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + const brightness = blockEntityBrightness(0, 1, 1) + expect(material.color.r).toBeCloseTo(base.r * brightness, 5) + expect(material.color.g).toBeCloseTo(base.g * brightness, 5) + expect(material.color.b).toBeCloseTo(base.b * brightness, 5) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(base.r * brightness, 5) + }) + + it('clones a shared material and leaves the original fullbright', () => { + const shared = new THREE.MeshBasicMaterial({ color: 0xff_00_00 }) + const { controller } = makeController(() => ({ block: 0, sky: 0 })) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), shared) + const root = new THREE.Group() + root.add(mesh) + controller.register(root, { eyeHeight: 0 }, new Set([shared])) + controller.update(root, { x: 1, y: 64, z: 1 }) + expect(shared.color.getHex()).toBe(0xff_00_00) + expect(mesh.material).not.toBe(shared) + const brightness = blockEntityBrightness(0, 0, 1) + expect((mesh.material as THREE.MeshBasicMaterial).color.r).toBeCloseTo(1 * brightness, 5) + }) + + it('keeps two roots independent and skips debug helpers', () => { + let sample = { block: 1, sky: 1 } + const { controller } = makeController(() => sample) + const brightMat = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const darkMat = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const debugMat = new THREE.MeshBasicMaterial({ color: 0x00_ff_00 }) + const bright = new THREE.Group() + bright.add(new THREE.Mesh(new THREE.BoxGeometry(), brightMat)) + const dark = new THREE.Group() + dark.add(new THREE.Mesh(new THREE.BoxGeometry(), darkMat)) + const debug = new THREE.Mesh(new THREE.BoxGeometry(), debugMat) + debug.name = 'debug' + dark.add(debug) + controller.register(bright, { eyeHeight: 0 }) + controller.register(dark, { eyeHeight: 0 }) + controller.update(bright, { x: 1, y: 64, z: 1 }) + sample = { block: 0, sky: 0 } + controller.update(dark, { x: 20, y: 64, z: 20 }) + expect(brightMat.color.r).toBeCloseTo(1, 5) + expect(darkMat.color.r).toBeCloseTo(blockEntityBrightness(0, 0, 1), 5) + expect(debugMat.color.getHex()).toBe(0x00_ff_00) + }) + + it('refreshes on sky change and stays fullbright when lighting is disabled', () => { + const { controller, setSkyLevel, setLightingEnabled } = makeController(() => ({ block: 0, sky: 1 })) + const material = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const root = new THREE.Group() + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)) + controller.register(root, { eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + setSkyLevel(nightSky) + expect(material.color.r).toBeCloseTo(blockEntityBrightness(0, 1, nightSky), 5) + setLightingEnabled(false) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(1, 5) + }) + + it('resamples a stationary root when the column revision changes', () => { + let sample = { block: 1, sky: 1 } + const { controller, bumpColumn } = makeController(() => sample) + const material = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const root = new THREE.Group() + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)) + controller.register(root, { eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(1, 5) + sample = { block: 0, sky: 0 } + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(1, 5) + bumpColumn() + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(blockEntityBrightness(0, 0, 1), 5) + }) + + it('does not resample when only a distant column revision changes', () => { + let sample = { block: 1, sky: 1 } + const { controller, bumpFarColumn } = makeController(() => sample) + const material = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const root = new THREE.Group() + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)) + controller.register(root, { eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(1, 5) + sample = { block: 0, sky: 0 } + bumpFarColumn() + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(1, 5) + }) + + it('adopts an authored material retint after invalidation', () => { + const { controller } = makeController(() => ({ block: 0, sky: 0 })) + const material = new THREE.MeshBasicMaterial({ color: 0xb5_6d_51 }) + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), material) + const root = new THREE.Group() + root.add(mesh) + controller.register(root, { eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + const brightness = blockEntityBrightness(0, 0, 1) + const brown = new THREE.Color(0xb5_6d_51) + expect(material.color.r).toBeCloseTo(brown.r * brightness, 5) + material.color.setHex(0x00_ff_00) + controller.invalidateMaterials(root) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(0, 5) + expect(material.color.g).toBeCloseTo(brightness, 5) + expect(material.color.b).toBeCloseTo(0, 5) + }) + + it('keeps glow item frames fullbright and recovers after a damage flash', () => { + const { controller } = makeController(() => ({ block: 0, sky: 0 })) + const material = new THREE.MeshBasicMaterial({ color: 0xaa_aa_aa }) + const baseR = material.color.r + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), material) + const root = new THREE.Group() + root.add(mesh) + controller.register(root, { name: 'glow_item_frame', eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(baseR, 5) + + const flashed = material.clone() + flashed.color.set(0xff_00_00) + mesh.material = flashed + controller.markFlash(root, 500) + controller.invalidateMaterials(root) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(flashed.color.getHex()).toBe(0xff_00_00) + controller.endFlash(root) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(flashed.color.r).toBeCloseTo(baseR, 5) + }) + + it('unregisters so later updates do not keep mutating materials', () => { + const { controller } = makeController(() => ({ block: 0, sky: 0 })) + const material = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) + const root = new THREE.Group() + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)) + controller.register(root, { eyeHeight: 0 }) + controller.update(root, { x: 8, y: 64, z: 8 }) + const dim = material.color.r + controller.unregister(root) + material.color.setHex(0xff_ff_ff) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.getHex()).toBe(0xff_ff_ff) + expect(dim).toBeLessThan(1) + }) +}) diff --git a/src/three/tests/rendererLightCache.test.ts b/src/three/tests/rendererLightCache.test.ts new file mode 100644 index 0000000..86465ef --- /dev/null +++ b/src/three/tests/rendererLightCache.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import Chunks from 'prismarine-chunk' +import MinecraftData from 'minecraft-data' +import { Vec3 } from 'vec3' +import { RENDERER_LIGHT_BYTES_PER_SECTION, RendererLightCache } from '../rendererLightCache' + +const VERSION = '1.16.5' + +function makeChunk() { + const Chunk = Chunks(VERSION) as any + return new Chunk(undefined as any) +} + +function makePresentZeroSection(y = 64) { + const mcData = MinecraftData(VERSION) + const chunk = makeChunk() + const stoneId = mcData.blocksByName.stone.defaultState + chunk.setBlockStateId(new Vec3(8, y, 8), stoneId) + // Allocate light sections, then zero them. All-zero light is otherwise omitted + // and must stay the fullbright "absent section" fallback. + chunk.setBlockLight(new Vec3(8, y, 8), 1) + chunk.setSkyLight(new Vec3(8, y, 8), 1) + chunk.setBlockLight(new Vec3(8, y, 8), 0) + chunk.setSkyLight(new Vec3(8, y, 8), 0) + return chunk +} + +function makeLitBlock(blockLight: number, skyLight: number, x = 8, y = 64, z = 8) { + const mcData = MinecraftData(VERSION) + const chunk = makeChunk() + const stoneId = mcData.blocksByName.stone.defaultState + chunk.setBlockStateId(new Vec3(x, y, z), stoneId) + chunk.setBlockLight(new Vec3(x, y, z), blockLight) + chunk.setSkyLight(new Vec3(x, y, z), skyLight) + return chunk +} + +describe('RendererLightCache', () => { + it('returns fullbright for a missing column or absent section', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + expect(cache.getLight(8, 64, 8)).toEqual({ block: 0, sky: 1 }) + + cache.ingestColumn(0, 0, makeChunk().toJson()) + expect(cache.getLight(8, 64, 8)).toEqual({ block: 0, sky: 1 }) + }) + + it('applies +2 floor for a present raw-zero section and clamps at 15', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + cache.ingestColumn(0, 0, makePresentZeroSection().toJson()) + expect(cache.getLight(8, 64, 8).block).toBeCloseTo(2 / 15, 5) + expect(cache.getLight(8, 64, 8).sky).toBeCloseTo(2 / 15, 5) + + cache.ingestColumn(0, 0, makeLitBlock(14, 14).toJson()) + expect(cache.getLight(8, 64, 8)).toEqual({ block: 1, sky: 1 }) + }) + + it('reads packed channels at negative chunk coordinates', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + cache.ingestColumn(-16, -16, makeLitBlock(13, 4, 15, 64, 15).toJson()) + const light = cache.getLight(-1, 64, -1) + expect(light.block).toBeCloseTo(15 / 15, 5) + expect(light.sky).toBeCloseTo(6 / 15, 5) + }) + + it('does not bump column revision on identical re-ingest', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const json = makeLitBlock(10, 5).toJson() + const first = cache.ingestColumn(0, 0, json) + const second = cache.ingestColumn(0, 0, json) + expect(first.changed).toBe(true) + expect(second.changed).toBe(false) + expect(second.revision).toBe(first.revision) + expect(cache.getColumnRevision(0, 0)).toBe(first.revision) + }) + + it('bumps revision when light changes and forgets removed columns', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const first = cache.ingestColumn(0, 0, makeLitBlock(3, 0).toJson()) + const second = cache.ingestColumn(0, 0, makeLitBlock(12, 0).toJson()) + expect(second.changed).toBe(true) + expect(second.revision).toBeGreaterThan(first.revision) + expect(cache.getLight(8, 64, 8).block).toBeCloseTo(14 / 15, 5) + + const globalBefore = cache.getGlobalRevision() + cache.removeColumn(0, 0) + expect(cache.getLight(8, 64, 8)).toEqual({ block: 0, sky: 1 }) + expect(cache.getGlobalRevision()).toBe(globalBefore) + expect(cache.getAllocatedSectionCount()).toBe(0) + }) + + it('does not bump global revision on ingest of another column', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + cache.ingestColumn(0, 0, makeLitBlock(3, 0).toJson()) + const globalBefore = cache.getGlobalRevision() + cache.ingestColumn(16, 0, makeLitBlock(12, 0).toJson()) + expect(cache.getGlobalRevision()).toBe(globalBefore) + expect(cache.getColumnRevision(16, 0)).toBeGreaterThan(0) + }) + + it('keeps prior column state when ingest fails', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + const ok = cache.ingestColumn(0, 0, makeLitBlock(8, 0).toJson()) + const failed = cache.ingestColumn(0, 0, { not: 'a-chunk' }) + expect(failed.changed).toBe(false) + expect(failed.revision).toBe(ok.revision) + expect(cache.getLight(8, 64, 8).block).toBeCloseTo(10 / 15, 5) + }) + + it('clear and bounds change drop light and bump the global revision', () => { + const cache = new RendererLightCache(VERSION) + cache.setWorldBounds(0, 256) + cache.ingestColumn(16, 32, makeLitBlock(15, 15).toJson()) + expect(cache.getAllocatedBytes()).toBeGreaterThanOrEqual(RENDERER_LIGHT_BYTES_PER_SECTION) + const globalBefore = cache.getGlobalRevision() + cache.clear() + expect(cache.getLight(24, 64, 40)).toEqual({ block: 0, sky: 1 }) + expect(cache.getGlobalRevision()).toBeGreaterThan(globalBefore) + }) +}) + +describe('RendererLightCache 1.18+', () => { + const VERSION_118 = '1.18.2' + const MIN_Y = -64 + const WORLD_HEIGHT = 384 + + function make118Chunk() { + const Chunk = Chunks(VERSION_118) as any + return new Chunk({ minY: MIN_Y, worldHeight: WORLD_HEIGHT }) + } + + function make118LitBlock(blockLight: number, skyLight: number, x = 8, y = -60, z = 8) { + const mcData = MinecraftData(VERSION_118) + const chunk = make118Chunk() + const stoneId = mcData.blocksByName.stone.defaultState + chunk.setBlockStateId(new Vec3(x, y, z), stoneId) + chunk.setBlockLight(new Vec3(x, y, z), blockLight) + chunk.setSkyLight(new Vec3(x, y, z), skyLight) + return chunk + } + + it('packs and reads light in a negative-Y section', () => { + const cache = new RendererLightCache(VERSION_118) + cache.setWorldBounds(MIN_Y, WORLD_HEIGHT) + cache.ingestColumn(0, 0, make118LitBlock(11, 3).toJson()) + const light = cache.getLight(8, -60, 8) + expect(light.block).toBeCloseTo(13 / 15, 5) + expect(light.sky).toBeCloseTo(5 / 15, 5) + expect(cache.getLight(8, -64, 8).block).toBeGreaterThan(0) + }) +}) diff --git a/src/three/worldRendererThree.ts b/src/three/worldRendererThree.ts index 85bf0b5..62134a0 100644 --- a/src/three/worldRendererThree.ts +++ b/src/three/worldRendererThree.ts @@ -39,6 +39,7 @@ import { FireworksManager } from './fireworks' import { SceneOrigin } from './sceneOrigin' import { downloadWorldGeometry } from './worldGeometryExport' import { ChunkMeshManager } from './chunkMeshManager' +import { EntityLightController } from './entityLightController' import { raycastVoxelSolid } from './thirdPersonVoxelRaycast' import type { RendererModuleManifest, RegisteredModule, RendererModuleController } from './rendererModuleSystem' import { BUILTIN_MODULES } from './modules/index' @@ -88,6 +89,7 @@ export class WorldRendererThree extends WorldRendererCommon { ambientLight = new THREE.AmbientLight(0xcc_cc_cc, WorldRendererThree.LEGACY_TO_PHYSICAL_LIGHT) directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5 * WorldRendererThree.LEGACY_TO_PHYSICAL_LIGHT) entities = new Entities(this, (globalThis as any).mcData) + entityLightController?: EntityLightController performanceMonitor!: PerformanceMonitor cameraGroupVr?: THREE.Object3D material = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 }) @@ -203,6 +205,14 @@ export class WorldRendererThree extends WorldRendererCommon { // Initialize chunk mesh manager this.chunkMeshManager = new ChunkMeshManager(this, this.scene, this.material, this.worldSizeParams.worldHeight, this.viewDistance) + this.entityLightController = new EntityLightController({ + getLight: (x, y, z) => this.rendererLightCache.getLight(x, y, z), + getSkyLevel: () => this.chunkMeshManager.getSkyLevel(), + getLightmapParams: () => this.chunkMeshManager.getLightmapParams(), + getColumnRevision: (cx, cz) => this.rendererLightCache.getColumnRevision(cx, cz), + getGlobalRevision: () => this.rendererLightCache.getGlobalRevision(), + lightingEnabled: () => this.worldRendererConfig.enableLighting !== false + }) this.onRenderDistanceChanged = viewDistance => { this.chunkMeshManager.updateViewDistance(viewDistance) } @@ -593,6 +603,9 @@ export class WorldRendererThree extends WorldRendererCommon { this.onReactiveConfigUpdated('futuristicReveal', () => { this.updateModulesFromConfig() }) + this.onReactiveConfigUpdated('enableLighting', () => { + this.rerenderAllChunks() + }, false) this.onReactiveDebugUpdated('disableEntities', () => { this.entities.syncSceneAttachment() }) @@ -825,6 +838,10 @@ export class WorldRendererThree extends WorldRendererCommon { if (camCollisionBytes > 0) { text += `CAM: ${formatCompact(camCollisionBytes)}b ` } + const entityLightBytes = this.rendererLightCache.getAllocatedBytes() + if (entityLightBytes > 0) { + text += `ELIGHT: ${formatCompact(entityLightBytes)}b ` + } const pf = formatPerformanceFactorsDebug(this.reactiveState.world.instabilityFactors) if (pf) text += `PF: ${pf} ` // entities can be seen in F3 diff --git a/src/wasm-mesher/worker/mesherWasm.ts b/src/wasm-mesher/worker/mesherWasm.ts index fb66c7d..52976f4 100644 --- a/src/wasm-mesher/worker/mesherWasm.ts +++ b/src/wasm-mesher/worker/mesherWasm.ts @@ -10,7 +10,7 @@ import { worldColumnKey, World } from '../../mesher-shared/world' import { handleGetHeightmap, EMPTY_COLUMN_HEIGHTMAP_SENTINEL } from '../../mesher-shared/computeHeightmap' import { collectBlockEntityMetadata, type SignMeta, type HeadMeta, type BannerMeta } from '../../mesher-shared/blockEntityMetadata' import { SectionRequestTracker } from './mesherWasmRequestTracker' -import { sectionYsForLightColumnDirty } from './mesherWasmLightDirty' +import { dropRawMapChunkOnLightOnlyReload, sectionYsForLightColumnDirty } from './mesherWasmLightDirty' import { CONVERSION_CACHE_LIMIT, clearConversionCache, getOrConvertColumn, invalidateConversion, setConversionCacheLimit } from './mesherWasmConversionCache' import { PendingChunkBuffer } from './mesherWasmChunkBuffer' import { @@ -382,6 +382,7 @@ function processChunkMessage(data: { x: number; z: number; chunk: any; customBlo function drainPendingChunks() { if (!world) return pendingChunks.drain(msg => { + dropRawMapChunkOnLightOnlyReload(msg.isLightUpdate, rawMapChunkCache, rawCacheKey(msg.x, msg.z)) processChunkMessage(msg) onColumnDataArrived(msg.x, msg.z) }) @@ -843,12 +844,14 @@ const handleMessage = async (data: any) => { // Invalidate BEFORE replacing the column reference so a stale entry // can never outlive the old chunk object. invalidateConversion(data.x, data.z) + dropRawMapChunkOnLightOnlyReload(data.isLightUpdate, rawMapChunkCache, rawCacheKey(data.x, data.z)) if (!world) { pendingChunks.enqueue({ x: data.x, z: data.z, chunk: data.chunk, - customBlockModels: data.customBlockModels + customBlockModels: data.customBlockModels, + isLightUpdate: data.isLightUpdate }) break } diff --git a/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts b/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts index 5c96035..e85e37f 100644 --- a/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts +++ b/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts @@ -3,6 +3,7 @@ export type PendingChunkMessage = { z: number chunk: any customBlockModels?: any + isLightUpdate?: boolean } export class PendingChunkBuffer { diff --git a/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts b/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts index 52450db..121e1f8 100644 --- a/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts +++ b/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts @@ -1,8 +1,21 @@ import { describe, expect, it } from 'vitest' -import { sectionYsForLightColumnDirty } from './mesherWasmLightDirty' +import { dropRawMapChunkOnLightOnlyReload, sectionYsForLightColumnDirty } from './mesherWasmLightDirty' describe('sectionYsForLightColumnDirty', () => { it('covers every section in a 256-high overworld column', () => { expect(sectionYsForLightColumnDirty(0, 256)).toEqual(Array.from({ length: 16 }, (_, i) => i * 16)) }) }) + +describe('dropRawMapChunkOnLightOnlyReload', () => { + it('deletes only the fused raw entry when the chunk reload is light-only', () => { + const raw = new Map([['0,0', 1], ['16,0', 2]]) + expect(dropRawMapChunkOnLightOnlyReload(false, raw, '0,0')).toBe(false) + expect(raw.has('0,0')).toBe(true) + expect(dropRawMapChunkOnLightOnlyReload(undefined, raw, '0,0')).toBe(false) + expect(raw.has('0,0')).toBe(true) + expect(dropRawMapChunkOnLightOnlyReload(true, raw, '0,0')).toBe(true) + expect(raw.has('0,0')).toBe(false) + expect(raw.has('16,0')).toBe(true) + }) +}) diff --git a/src/wasm-mesher/worker/mesherWasmLightDirty.ts b/src/wasm-mesher/worker/mesherWasmLightDirty.ts index 79145b3..a97a383 100644 --- a/src/wasm-mesher/worker/mesherWasmLightDirty.ts +++ b/src/wasm-mesher/worker/mesherWasmLightDirty.ts @@ -8,3 +8,17 @@ export function sectionYsForLightColumnDirty(worldMinY: number, worldMaxY: numbe } return ys } + +/** + * 1.18+ fused meshing prefers the original raw map_chunk. A later light-only + * column reload must drop that entry so the JSON column walk is used. + * Normal chunk loads must keep it (`setRawMapChunk` arrives first). + */ +export function dropRawMapChunkOnLightOnlyReload( + isLightUpdate: boolean | undefined, + rawCache: { delete: (key: string) => boolean }, + key: string +): boolean { + if (!isLightUpdate) return false + return rawCache.delete(key) +} From 6de35a3f01508256b679e99d1e1b4ea1c79e514c Mon Sep 17 00:00:00 2001 From: zverev Date: Tue, 1 Sep 2026 21:38:18 +0300 Subject: [PATCH 2/4] chore: format prittier --- src/graphicsBackend/rendererDefaultOptions.test.ts | 6 +----- src/graphicsBackend/rendererDefaultOptions.ts | 10 ++-------- src/graphicsBackend/rendererOptionsSync.ts | 11 ++--------- src/three/worldRendererThree.ts | 10 +++++++--- src/wasm-mesher/worker/mesherWasmLightDirty.test.ts | 5 ++++- src/wasm-mesher/worker/mesherWasmLightDirty.ts | 6 +----- 6 files changed, 17 insertions(+), 31 deletions(-) diff --git a/src/graphicsBackend/rendererDefaultOptions.test.ts b/src/graphicsBackend/rendererDefaultOptions.test.ts index da7a7b8..88f0115 100644 --- a/src/graphicsBackend/rendererDefaultOptions.test.ts +++ b/src/graphicsBackend/rendererDefaultOptions.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - migrateRendererOptions, - resolveEnableLighting, - upgradeStoredNewVersionsLightingDefault -} from './rendererDefaultOptions' +import { migrateRendererOptions, resolveEnableLighting, upgradeStoredNewVersionsLightingDefault } from './rendererDefaultOptions' describe('migrateRendererOptions', () => { it('drops the leaked migration flag so it is not a user option', () => { diff --git a/src/graphicsBackend/rendererDefaultOptions.ts b/src/graphicsBackend/rendererDefaultOptions.ts index 9af390a..73c8131 100644 --- a/src/graphicsBackend/rendererDefaultOptions.ts +++ b/src/graphicsBackend/rendererDefaultOptions.ts @@ -131,10 +131,7 @@ export function migrateRendererOptions(saved: Record): void { * `alreadyMigrated` must live outside user-visible options (e.g. a dedicated localStorage key). * Returns true so the caller can persist that the upgrade has run. */ -export function upgradeStoredNewVersionsLightingDefault( - saved: Record, - alreadyMigrated: boolean -): boolean { +export function upgradeStoredNewVersionsLightingDefault(saved: Record, alreadyMigrated: boolean): boolean { if (!alreadyMigrated && saved.newVersionsLighting === false) { delete saved.newVersionsLighting } @@ -145,10 +142,7 @@ export function upgradeStoredNewVersionsLightingDefault( * Runtime `enableLighting` from the stored option and protocol. * Pre-1.13 (no blockStateId) always lights. Missing protocol (menu, no bot) must not throw. */ -export function resolveEnableLighting( - newVersionsLighting: boolean, - blockStateIdSupported: boolean | undefined -): boolean { +export function resolveEnableLighting(newVersionsLighting: boolean, blockStateIdSupported: boolean | undefined): boolean { return blockStateIdSupported !== true || newVersionsLighting } diff --git a/src/graphicsBackend/rendererOptionsSync.ts b/src/graphicsBackend/rendererOptionsSync.ts index 0b2b0b0..6db5f81 100644 --- a/src/graphicsBackend/rendererOptionsSync.ts +++ b/src/graphicsBackend/rendererOptionsSync.ts @@ -215,13 +215,6 @@ export function subscribeRendererOptions( } /** Call when mineflayer bot is created (lighting depends on protocol features). */ -export function applyRendererEnableLighting( - appViewer: AppViewer, - newVersionsLighting: boolean, - blockStateIdSupported: boolean | undefined -): void { - appViewer.inWorldRenderingConfig.enableLighting = resolveEnableLighting( - newVersionsLighting, - blockStateIdSupported - ) +export function applyRendererEnableLighting(appViewer: AppViewer, newVersionsLighting: boolean, blockStateIdSupported: boolean | undefined): void { + appViewer.inWorldRenderingConfig.enableLighting = resolveEnableLighting(newVersionsLighting, blockStateIdSupported) } diff --git a/src/three/worldRendererThree.ts b/src/three/worldRendererThree.ts index 62134a0..6de2cdb 100644 --- a/src/three/worldRendererThree.ts +++ b/src/three/worldRendererThree.ts @@ -603,9 +603,13 @@ export class WorldRendererThree extends WorldRendererCommon { this.onReactiveConfigUpdated('futuristicReveal', () => { this.updateModulesFromConfig() }) - this.onReactiveConfigUpdated('enableLighting', () => { - this.rerenderAllChunks() - }, false) + this.onReactiveConfigUpdated( + 'enableLighting', + () => { + this.rerenderAllChunks() + }, + false + ) this.onReactiveDebugUpdated('disableEntities', () => { this.entities.syncSceneAttachment() }) diff --git a/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts b/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts index 121e1f8..a55b708 100644 --- a/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts +++ b/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts @@ -9,7 +9,10 @@ describe('sectionYsForLightColumnDirty', () => { describe('dropRawMapChunkOnLightOnlyReload', () => { it('deletes only the fused raw entry when the chunk reload is light-only', () => { - const raw = new Map([['0,0', 1], ['16,0', 2]]) + const raw = new Map([ + ['0,0', 1], + ['16,0', 2] + ]) expect(dropRawMapChunkOnLightOnlyReload(false, raw, '0,0')).toBe(false) expect(raw.has('0,0')).toBe(true) expect(dropRawMapChunkOnLightOnlyReload(undefined, raw, '0,0')).toBe(false) diff --git a/src/wasm-mesher/worker/mesherWasmLightDirty.ts b/src/wasm-mesher/worker/mesherWasmLightDirty.ts index a97a383..26a9bd5 100644 --- a/src/wasm-mesher/worker/mesherWasmLightDirty.ts +++ b/src/wasm-mesher/worker/mesherWasmLightDirty.ts @@ -14,11 +14,7 @@ export function sectionYsForLightColumnDirty(worldMinY: number, worldMaxY: numbe * column reload must drop that entry so the JSON column walk is used. * Normal chunk loads must keep it (`setRawMapChunk` arrives first). */ -export function dropRawMapChunkOnLightOnlyReload( - isLightUpdate: boolean | undefined, - rawCache: { delete: (key: string) => boolean }, - key: string -): boolean { +export function dropRawMapChunkOnLightOnlyReload(isLightUpdate: boolean | undefined, rawCache: { delete: (key: string) => boolean }, key: string): boolean { if (!isLightUpdate) return false return rawCache.delete(key) } From 465cf22a227c3aea7eaddd537c2bd4fdd5079d25 Mon Sep 17 00:00:00 2001 From: zverev Date: Tue, 22 Sep 2026 22:09:19 +0300 Subject: [PATCH 3/4] fix: keep newer-version lighting opt-in --- .../rendererDefaultOptions.test.ts | 21 +++++++++---------- src/graphicsBackend/rendererDefaultOptions.ts | 14 +------------ 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/src/graphicsBackend/rendererDefaultOptions.test.ts b/src/graphicsBackend/rendererDefaultOptions.test.ts index 88f0115..a6ed053 100644 --- a/src/graphicsBackend/rendererDefaultOptions.test.ts +++ b/src/graphicsBackend/rendererDefaultOptions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { migrateRendererOptions, resolveEnableLighting, upgradeStoredNewVersionsLightingDefault } from './rendererDefaultOptions' +import { migrateRendererOptions, RENDERER_DEFAULT_OPTIONS, resolveEnableLighting } from './rendererDefaultOptions' describe('migrateRendererOptions', () => { it('drops the leaked migration flag so it is not a user option', () => { @@ -13,22 +13,21 @@ describe('migrateRendererOptions', () => { }) }) -describe('upgradeStoredNewVersionsLightingDefault', () => { - it('drops a stored false from the old default on first upgrade', () => { - const saved: Record = { newVersionsLighting: false } - expect(upgradeStoredNewVersionsLightingDefault(saved, false)).toBe(true) - expect(saved).not.toHaveProperty('newVersionsLighting') +describe('newer-version lighting defaults', () => { + it('keeps lighting opt-in on 1.13+', () => { + expect(RENDERER_DEFAULT_OPTIONS.newVersionsLighting).toBe(false) + expect(resolveEnableLighting(RENDERER_DEFAULT_OPTIONS.newVersionsLighting, true)).toBe(false) }) - it('keeps an explicit false after the upgrade has already run', () => { - const saved: Record = { newVersionsLighting: false } - expect(upgradeStoredNewVersionsLightingDefault(saved, true)).toBe(true) - expect(saved.newVersionsLighting).toBe(false) + it.each([false, true])('preserves a saved lighting preference (%s)', value => { + const saved: Record = { newVersionsLighting: value } + migrateRendererOptions(saved) + expect(saved.newVersionsLighting).toBe(value) }) it('does not invent a stored value when the key was never saved', () => { const saved: Record = {} - expect(upgradeStoredNewVersionsLightingDefault(saved, false)).toBe(true) + migrateRendererOptions(saved) expect(saved).not.toHaveProperty('newVersionsLighting') }) }) diff --git a/src/graphicsBackend/rendererDefaultOptions.ts b/src/graphicsBackend/rendererDefaultOptions.ts index 73c8131..065a204 100644 --- a/src/graphicsBackend/rendererDefaultOptions.ts +++ b/src/graphicsBackend/rendererDefaultOptions.ts @@ -65,7 +65,7 @@ export const RENDERER_DEFAULT_OPTIONS = { backgroundRendering: '20fps' as 'full' | '20fps' | '5fps', vanillaLook: false as boolean, smoothLighting: true as boolean, - newVersionsLighting: true as boolean, + newVersionsLighting: false as boolean, vrSupport: true as boolean, vrPageGameRendering: false as boolean, clipWorldBelowY: undefined as number | undefined, @@ -126,18 +126,6 @@ export function migrateRendererOptions(saved: Record): void { delete saved.migratedNewVersionsLightingDefault } -/** - * One-shot upgrade of the old default (`false`) to the current default (`true`). - * `alreadyMigrated` must live outside user-visible options (e.g. a dedicated localStorage key). - * Returns true so the caller can persist that the upgrade has run. - */ -export function upgradeStoredNewVersionsLightingDefault(saved: Record, alreadyMigrated: boolean): boolean { - if (!alreadyMigrated && saved.newVersionsLighting === false) { - delete saved.newVersionsLighting - } - return true -} - /** * Runtime `enableLighting` from the stored option and protocol. * Pre-1.13 (no blockStateId) always lights. Missing protocol (menu, no bot) must not throw. From 0f2a5fc63018033e40852297bb1b2e34a6abc863 Mon Sep 17 00:00:00 2001 From: zverev Date: Wed, 23 Sep 2026 23:07:58 +0300 Subject: [PATCH 4/4] feat(render): make entity lighting opt-in --- src/graphicsBackend/config.ts | 2 ++ .../rendererDefaultOptions.test.ts | 12 ++++++++ src/graphicsBackend/rendererDefaultOptions.ts | 8 ++++- .../rendererOptionsSync.test.ts | 22 ++++++++++++++ src/graphicsBackend/rendererOptionsSync.ts | 1 + src/three/tests/entityLightController.test.ts | 29 +++++++++++++++++++ src/three/worldRendererThree.ts | 2 +- 7 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src/graphicsBackend/rendererOptionsSync.test.ts diff --git a/src/graphicsBackend/config.ts b/src/graphicsBackend/config.ts index 78c00e3..a700c1d 100644 --- a/src/graphicsBackend/config.ts +++ b/src/graphicsBackend/config.ts @@ -51,6 +51,8 @@ export const defaultWorldRendererConfig = { dayCycle: true, smoothLighting: true, enableLighting: true, + /** Entity shading is independently opt-in; block lighting remains controlled by enableLighting. */ + enableEntityLighting: false, starfield: true, defaultSkybox: true, renderEntities: true, diff --git a/src/graphicsBackend/rendererDefaultOptions.test.ts b/src/graphicsBackend/rendererDefaultOptions.test.ts index a6ed053..37535a5 100644 --- a/src/graphicsBackend/rendererDefaultOptions.test.ts +++ b/src/graphicsBackend/rendererDefaultOptions.test.ts @@ -19,16 +19,28 @@ describe('newer-version lighting defaults', () => { expect(resolveEnableLighting(RENDERER_DEFAULT_OPTIONS.newVersionsLighting, true)).toBe(false) }) + it('defaults entity lighting to off independently of world lighting', () => { + expect(RENDERER_DEFAULT_OPTIONS.entityLighting).toBe(false) + }) + it.each([false, true])('preserves a saved lighting preference (%s)', value => { const saved: Record = { newVersionsLighting: value } migrateRendererOptions(saved) expect(saved.newVersionsLighting).toBe(value) }) + it('does not migrate old world-lighting settings into entity lighting', () => { + const saved: Record = { newVersionsLighting: true } + migrateRendererOptions(saved) + expect(saved.newVersionsLighting).toBe(true) + expect(saved).not.toHaveProperty('entityLighting') + }) + it('does not invent a stored value when the key was never saved', () => { const saved: Record = {} migrateRendererOptions(saved) expect(saved).not.toHaveProperty('newVersionsLighting') + expect(saved).not.toHaveProperty('entityLighting') }) }) diff --git a/src/graphicsBackend/rendererDefaultOptions.ts b/src/graphicsBackend/rendererDefaultOptions.ts index 065a204..b27c5f2 100644 --- a/src/graphicsBackend/rendererDefaultOptions.ts +++ b/src/graphicsBackend/rendererDefaultOptions.ts @@ -66,6 +66,7 @@ export const RENDERER_DEFAULT_OPTIONS = { vanillaLook: false as boolean, smoothLighting: true as boolean, newVersionsLighting: false as boolean, + entityLighting: false as boolean, vrSupport: true as boolean, vrPageGameRendering: false as boolean, clipWorldBelowY: undefined as number | undefined, @@ -247,7 +248,11 @@ export const RENDERER_OPTIONS_META: Partial ({ setSkinsConfig: () => {} })) + +import { createWorldRendererConfig } from './config' +import { RENDERER_DEFAULT_OPTIONS } from './rendererDefaultOptions' +import { applyRendererOptions } from './rendererOptionsSync' + +describe('applyRendererOptions entity lighting', () => { + it.each([false, true])('syncs entityLighting=%s to the runtime config', entityLighting => { + const inWorldRenderingConfig = createWorldRendererConfig() + const appViewer = { + inWorldRenderingConfig, + config: {}, + currentDisplay: null + } as any + + applyRendererOptions(appViewer, { ...RENDERER_DEFAULT_OPTIONS, entityLighting }) + + expect(inWorldRenderingConfig.enableEntityLighting).toBe(entityLighting) + }) +}) diff --git a/src/graphicsBackend/rendererOptionsSync.ts b/src/graphicsBackend/rendererOptionsSync.ts index 6db5f81..5c5a4d3 100644 --- a/src/graphicsBackend/rendererOptionsSync.ts +++ b/src/graphicsBackend/rendererOptionsSync.ts @@ -132,6 +132,7 @@ export function applyRendererOptions(appViewer: AppViewer, o: RendererStorageOpt cfg.futuristicReveal = o.rendererFuturisticReveal applyMesherWorkersPreset(appViewer, o, wasmActive) cfg.renderEntities = o.renderEntities + cfg.enableEntityLighting = o.entityLighting applyStatsVisible(appViewer, o, ctx) applyFpsLimit(appViewer, o, ctx.windowFocused !== false) diff --git a/src/three/tests/entityLightController.test.ts b/src/three/tests/entityLightController.test.ts index fb8b1dc..a656a7c 100644 --- a/src/three/tests/entityLightController.test.ts +++ b/src/three/tests/entityLightController.test.ts @@ -110,6 +110,35 @@ describe('EntityLightController', () => { expect(debugMat.color.getHex()).toBe(0x00_ff_00) }) + it('updates existing entities across entity/world lighting toggles without tint accumulation', () => { + const { controller, setLightingEnabled } = makeController(() => ({ block: 0, sky: 0 })) + const material = new THREE.MeshBasicMaterial({ color: 0xb5_6d_51 }) + const base = material.color.clone() + const root = new THREE.Group() + root.add(new THREE.Mesh(new THREE.BoxGeometry(), material)) + controller.register(root, { eyeHeight: 0 }) + + // Entity lighting is independently disabled initially. + setLightingEnabled(false) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(base.r, 5) + expect(material.color.g).toBeCloseTo(base.g, 5) + expect(material.color.b).toBeCloseTo(base.b, 5) + + setLightingEnabled(true) + controller.update(root, { x: 8, y: 64, z: 8 }) + const dim = blockEntityBrightness(0, 0, 1) + expect(material.color.r).toBeCloseTo(base.r * dim, 5) + expect(material.color.g).toBeCloseTo(base.g * dim, 5) + expect(material.color.b).toBeCloseTo(base.b * dim, 5) + + setLightingEnabled(false) + controller.update(root, { x: 8, y: 64, z: 8 }) + expect(material.color.r).toBeCloseTo(base.r, 5) + expect(material.color.g).toBeCloseTo(base.g, 5) + expect(material.color.b).toBeCloseTo(base.b, 5) + }) + it('refreshes on sky change and stays fullbright when lighting is disabled', () => { const { controller, setSkyLevel, setLightingEnabled } = makeController(() => ({ block: 0, sky: 1 })) const material = new THREE.MeshBasicMaterial({ color: 0xff_ff_ff }) diff --git a/src/three/worldRendererThree.ts b/src/three/worldRendererThree.ts index 6de2cdb..7d000c7 100644 --- a/src/three/worldRendererThree.ts +++ b/src/three/worldRendererThree.ts @@ -211,7 +211,7 @@ export class WorldRendererThree extends WorldRendererCommon { getLightmapParams: () => this.chunkMeshManager.getLightmapParams(), getColumnRevision: (cx, cz) => this.rendererLightCache.getColumnRevision(cx, cz), getGlobalRevision: () => this.rendererLightCache.getGlobalRevision(), - lightingEnabled: () => this.worldRendererConfig.enableLighting !== false + lightingEnabled: () => this.worldRendererConfig.enableLighting !== false && this.worldRendererConfig.enableEntityLighting === true }) this.onRenderDistanceChanged = viewDistance => { this.chunkMeshManager.updateViewDistance(viewDistance)