Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions renderer/viewer/lib/chunkCacheIntegration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Chunk Geometry Cache Integration
*
* This module provides integration between the chunk geometry cache
* and the world renderer system. It handles:
* - Computing block hashes for cache keys
* - Checking cache before requesting geometry from workers
* - Saving generated geometry to cache
*/

import type { MesherGeometryOutput } from './mesher/shared'

// Store for block state IDs by section for hash computation
const sectionBlockStates = new Map<string, Uint16Array>()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite understand how this cache is used, don't we already have cache in the world renderer? We better not introduce unscoped variables (it's fine to split some methods into its file like you done).


/**
* Store block state IDs for a section (called when chunk data is loaded)
*/
export function storeSectionBlockStates (
sectionKey: string,
blockStateIds: Uint16Array | number[]
): void {
const data = blockStateIds instanceof Uint16Array
? blockStateIds
: new Uint16Array(blockStateIds)
sectionBlockStates.set(sectionKey, data)
}

/**
* Get stored block state IDs for a section
*/
export function getSectionBlockStates (sectionKey: string): Uint16Array | null {
return sectionBlockStates.get(sectionKey) || null
}

/**
* Clear block state data for a section
*/
export function clearSectionBlockStates (sectionKey: string): void {
sectionBlockStates.delete(sectionKey)
}

/**
* Clear all stored block state data
*/
export function clearAllBlockStates (): void {
sectionBlockStates.clear()
}

/**
* Compute a simple hash from block state IDs
* Uses a fast non-cryptographic hash for performance
*/
export function computeBlockHash (blockStateIds: Uint16Array): string {
// Use FNV-1a hash for fast hashing
let hash = 2_166_136_261 // FNV offset basis
for (const stateId of blockStateIds) {
hash ^= stateId
hash = Math.imul(hash, 16_777_619) // FNV prime
}
// Convert to unsigned 32-bit and then to hex
return (hash >>> 0).toString(16).padStart(8, '0')
}

/**
* Generate a simple hash from block state IDs (async version using crypto.subtle)
* Use this for more secure hashing when persistent storage is used
*/
export async function computeBlockHashAsync (blockStateIds: Uint16Array): Promise<string> {
if (globalThis.crypto?.subtle) {
try {
// Pass the typed array view directly (not .buffer which includes the entire ArrayBuffer)
const viewBytes = new Uint8Array(blockStateIds.buffer, blockStateIds.byteOffset, blockStateIds.byteLength)
const buffer = await crypto.subtle.digest('SHA-256', viewBytes)
const hashArray = [...new Uint8Array(buffer)]
// Use first 8 bytes for a shorter hash
return hashArray.slice(0, 8).map(b => b.toString(16).padStart(2, '0')).join('')
} catch {
// Fall back to simple hash
return computeBlockHash(blockStateIds)
}
}
return computeBlockHash(blockStateIds)
}

/**
* Check if geometry data is valid and can be cached
*/
export function isGeometryCacheable (geometry: MesherGeometryOutput): boolean {
// Don't cache empty geometry or geometry with errors
return Boolean(geometry.positions?.length) && !geometry.hadErrors
}

/**
* Get section coordinates from section key
*/
export function parseSectionKey (sectionKey: string): { x: number; y: number; z: number } | null {
const parts = sectionKey.split(',')
if (parts.length !== 3) return null
const [x, y, z] = parts.map(Number)
if (Number.isNaN(x) || Number.isNaN(y) || Number.isNaN(z)) return null
return { x, y, z }
}

/**
* Create a section key from coordinates
*/
export function createSectionKey (x: number, y: number, z: number): string {
return `${x},${y},${z}`
}

/**
* Create a chunk key from coordinates
*/
export function createChunkKey (x: number, z: number): string {
return `${x},${z}`
}

/**
* Compute a hash from raw chunk data (ArrayBuffer or array)
* Uses FNV-1a for fast hashing
*/
export function computeChunkDataHash (chunkData: ArrayBuffer | ArrayLike<number>): string {
// Convert to Uint8Array - works with both ArrayBuffer and ArrayLike<number>
const data = new Uint8Array(
// eslint-disable-next-line unicorn/prefer-spread -- ArrayLike is not Iterable
chunkData instanceof ArrayBuffer ? chunkData : Array.from(chunkData)
)

// Use FNV-1a hash
let hash = 2_166_136_261 // FNV offset basis
for (const byte of data) {
hash ^= byte
hash = Math.imul(hash, 16_777_619) // FNV prime
}
return (hash >>> 0).toString(16).padStart(8, '0')
}
86 changes: 85 additions & 1 deletion renderer/viewer/lib/worldrendererCommon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import type { ResourcesManagerTransferred } from '../../../src/resourcesManager'
import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState } from '../../../src/appViewer'
import { SoundSystem } from '../three/threeJsSound'
import { buildCleanupDecorator } from './cleanupDecorator'
import { HighestBlockInfo, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig, MesherMainEvent } from './mesher/shared'
import { HighestBlockInfo, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig, MesherMainEvent, MesherGeometryOutput } from './mesher/shared'
import { chunkPos } from './simpleUtils'
import { addNewStat, removeAllStats, updatePanesVisibility, updateStatText } from './ui/newStats'
import { WorldDataEmitterWorker } from './worldDataEmitter'
import { computeBlockHash, storeSectionBlockStates, clearSectionBlockStates, clearAllBlockStates, getSectionBlockStates, isGeometryCacheable, computeChunkDataHash } from './chunkCacheIntegration'
import { getPlayerStateUtils, PlayerStateReactive, PlayerStateRenderer, PlayerStateUtils } from './basePlayerState'
import { MesherLogReader } from './mesherlogReader'
import { setSkinsConfig } from './utils/skins'
Expand Down Expand Up @@ -198,6 +199,14 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
chunksFullInfo = '-'
workerCustomHandleTime = 0

// Chunk geometry cache properties
@worldCleanup()
geometryCache = new Map<string, { hash: string; geometry: MesherGeometryOutput }>()
@worldCleanup()
sectionHashes = new Map<string, string>()
geometryCacheHits = 0
geometryCacheMisses = 0

get version () {
return this.displayOptions.version
}
Expand Down Expand Up @@ -407,6 +416,16 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.geometryReceiveCount[data.workerIndex]++
const chunkCoords = data.key.split(',').map(Number)
this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2])))

// Cache the geometry for later reuse
if (isGeometryCacheable(data.geometry)) {
const sectionHash = this.sectionHashes.get(data.key) || 'unknown'
this.geometryCache.set(data.key, {
hash: sectionHash,
geometry: data.geometry
})
this.geometryCacheMisses++
}
}
if (data.type === 'sectionFinished') { // on after load & unload section
this.logWorkerWork(`<- ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`)
Expand Down Expand Up @@ -645,6 +664,13 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
const chunkKey = `${x},${z}`
const customBlockModels = this.protocolCustomBlocks.get(chunkKey)

// Compute hash from chunk data for cache validation
const chunkHash = computeChunkDataHash(chunk)
for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
const sectionKey = `${x},${y},${z}`
this.sectionHashes.set(sectionKey, chunkHash)
}

for (const worker of this.workers) {
worker.postMessage({
type: 'chunk',
Expand Down Expand Up @@ -848,11 +874,24 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
})
}

/**
* Invalidate cached geometry for a section (called when blocks change)
*/
invalidateSectionCache (pos: Vec3): void {
const sectionKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}`
this.geometryCache.delete(sectionKey)
this.sectionHashes.delete(sectionKey)
clearSectionBlockStates(sectionKey)
}

setBlockStateIdInner (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {
const chunkKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`
const blockPosKey = `${pos.x},${pos.y},${pos.z}`
const customBlockModels = this.protocolCustomBlocks.get(chunkKey) || {}

// Invalidate cache for the affected section since a block changed
this.invalidateSectionCache(pos)

for (const worker of this.workers) {
worker.postMessage({
type: 'blockUpdate',
Expand Down Expand Up @@ -930,6 +969,44 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
return Promise.all(data)
}

/**
* Try to use cached geometry for a section instead of regenerating
* Returns true if cache was used, false otherwise
*/
tryUseCachedGeometry (sectionKey: string): boolean {
const cached = this.geometryCache.get(sectionKey)
if (!cached) return false

// Validate that the cached hash matches the current section hash
const currentHash = this.sectionHashes.get(sectionKey)
if (!currentHash || cached.hash !== currentHash) return false

// Use the cached geometry by simulating a worker message
this.geometryCacheHits++
const fakeWorkerMessage = {
type: 'geometry' as const,
key: sectionKey,
geometry: cached.geometry,
workerIndex: -1 // Mark as cached
}

// Process the cached geometry
this.handleWorkerMessage(fakeWorkerMessage as any)

// Complete the sectionFinished workflow for proper tracking
// Simulate the sectionsWaiting tracking that would happen for a real worker request
this.sectionsWaiting.set(sectionKey, (this.sectionsWaiting.get(sectionKey) ?? 0) + 1)
// Process sectionFinished through handleMessage for proper bookkeeping
this.handleMessage({
type: 'sectionFinished',
key: sectionKey,
workerIndex: -1,
processTime: 0
})

return true
}

setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks
if (!this.forceCallFromMesherReplayer && this.mesherLogReader) return

Expand All @@ -939,6 +1016,13 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// todo shouldnt we check loadedChunks instead?
if (!this.workers.length || distance[0] > this.viewDistance || distance[1] > this.viewDistance) return
const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}`

// Try to use cached geometry if available (only when setting dirty, not when clearing)
// Skip cache when using change worker to ensure proper tracking
if (value && !useChangeWorker && this.tryUseCachedGeometry(key)) {
this.logWorkerWork(() => `<- cache hit for section ${key}`)
return
}
// if (this.sectionsOutstanding.has(key)) return
this.renderUpdateEmitter.emit('dirty', pos, value)
// Dispatch sections to workers based on position
Expand Down
Loading
Loading