From 97f8061b06c42be258327c5f839413c156c162fd Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 25 Jun 2025 17:25:33 +0300 Subject: [PATCH 01/86] feat: Implement instanced rendering mode for low-end devices and performance testing --- renderer/viewer/lib/mesher/models.ts | 248 +++++++++- renderer/viewer/lib/mesher/shared.ts | 20 +- renderer/viewer/lib/worldrendererCommon.ts | 11 +- renderer/viewer/three/instancedRenderer.ts | 494 ++++++++++++++++++++ renderer/viewer/three/worldrendererThree.ts | 31 +- src/optionsStorage.ts | 5 + src/react/RendererDebugMenu.tsx | 33 +- src/watchOptions.ts | 8 + 8 files changed, 842 insertions(+), 8 deletions(-) create mode 100644 renderer/viewer/three/instancedRenderer.ts diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index a69d3e9a..9e928ccb 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -5,8 +5,141 @@ import { BlockType } from '../../../playground/shared' import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock } from './world' import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon' import { INVISIBLE_BLOCKS } from './worldConstants' -import { MesherGeometryOutput, HighestBlockInfo } from './shared' +import { MesherGeometryOutput, HighestBlockInfo, InstancedBlockEntry } from './shared' +// Hardcoded list of full blocks that can use instancing +export const INSTANCEABLE_BLOCKS = new Set([ + 'grass_block', + 'dirt', + 'stone', + 'cobblestone', + 'mossy_cobblestone', + 'clay', + 'moss_block', + 'spawner', + 'sand', + 'gravel', + 'oak_planks', + 'birch_planks', + 'spruce_planks', + 'jungle_planks', + 'acacia_planks', + 'dark_oak_planks', + 'mangrove_planks', + 'cherry_planks', + 'bamboo_planks', + 'crimson_planks', + 'warped_planks', + 'iron_block', + 'gold_block', + 'diamond_block', + 'emerald_block', + 'netherite_block', + 'coal_block', + 'redstone_block', + 'lapis_block', + 'copper_block', + 'exposed_copper', + 'weathered_copper', + 'oxidized_copper', + 'cut_copper', + 'exposed_cut_copper', + 'weathered_cut_copper', + 'oxidized_cut_copper', + 'waxed_copper_block', + 'waxed_exposed_copper', + 'waxed_weathered_copper', + 'waxed_oxidized_copper', + 'raw_iron_block', + 'raw_copper_block', + 'raw_gold_block', + 'smooth_stone', + 'cobbled_deepslate', + 'deepslate', + 'calcite', + 'tuff', + 'dripstone_block', + 'amethyst_block', + 'budding_amethyst', + 'obsidian', + 'crying_obsidian', + 'bedrock', + 'end_stone', + 'purpur_block', + 'quartz_block', + 'smooth_quartz', + 'nether_bricks', + 'red_nether_bricks', + 'blackstone', + 'gilded_blackstone', + 'polished_blackstone', + 'chiseled_nether_bricks', + 'cracked_nether_bricks', + 'basalt', + 'smooth_basalt', + 'polished_basalt', + 'netherrack', + 'magma_block', + 'soul_sand', + 'soul_soil', + 'ancient_debris', + 'bone_block', + 'packed_ice', + 'blue_ice', + 'ice', + 'snow_block', + 'powder_snow', + 'white_wool', + 'orange_wool', + 'magenta_wool', + 'light_blue_wool', + 'yellow_wool', + 'lime_wool', + 'pink_wool', + 'gray_wool', + 'light_gray_wool', + 'cyan_wool', + 'purple_wool', + 'blue_wool', + 'brown_wool', + 'green_wool', + 'red_wool', + 'black_wool', + 'white_concrete', + 'orange_concrete', + 'magenta_concrete', + 'light_blue_concrete', + 'yellow_concrete', + 'lime_concrete', + 'pink_concrete', + 'gray_concrete', + 'light_gray_concrete', + 'cyan_concrete', + 'purple_concrete', + 'blue_concrete', + 'brown_concrete', + 'green_concrete', + 'red_concrete', + 'black_concrete', + 'white_terracotta', + 'orange_terracotta', + 'magenta_terracotta', + 'light_blue_terracotta', + 'yellow_terracotta', + 'lime_terracotta', + 'pink_terracotta', + 'gray_terracotta', + 'light_gray_terracotta', + 'cyan_terracotta', + 'purple_terracotta', + 'blue_terracotta', + 'brown_terracotta', + 'green_terracotta', + 'red_terracotta', + 'black_terracotta', + 'terracotta', + 'glazed_terracotta', +]) let blockProvider: WorldBlockProvider @@ -518,10 +651,87 @@ const isBlockWaterlogged = (block: Block) => { return block.getProperties().waterlogged === true || block.getProperties().waterlogged === 'true' || ALWAYS_WATERLOGGED.has(block.name) } +const shouldCullInstancedBlock = (world: World, cursor: Vec3, block: Block): boolean => { + // Check if all 6 faces would be culled (hidden by neighbors) + const cullIfIdentical = block.name.includes('glass') || block.name.includes('ice') + + // eslint-disable-next-line guard-for-in + for (const face in elemFaces) { + const { dir } = elemFaces[face] + const neighbor = world.getBlock(cursor.plus(new Vec3(dir[0], dir[1], dir[2])), blockProvider, {}) + + if (!neighbor) { + // Face is exposed to air/void + return false + } + + if (cullIfIdentical && neighbor.stateId === block.stateId) { + // Same block type, face should be culled + continue + } + + if (!neighbor.transparent && isCube(neighbor)) { + // Neighbor is opaque and full cube, face should be culled + continue + } + + // Face is not culled, block should be rendered + return false + } + + // All faces are culled, block should not be rendered + return true +} + +const getInstancedBlockTextureInfo = (block: Block) => { + // Get texture info from the first available face + const model = block.models?.[0]?.[0] + if (!model?.elements?.[0]) return undefined + + const element = model.elements[0] + + // Try to find a face with texture - prefer visible faces + const faceOrder = ['up', 'north', 'east', 'south', 'west', 'down'] + for (const faceName of faceOrder) { + const face = element.faces[faceName] + if (face?.texture) { + const texture = face.texture as any + return { + u: texture.u || 0, + v: texture.v || 0, + su: texture.su || 1, + sv: texture.sv || 1 + } + } + } + + return undefined +} + +const isBlockInstanceable = (block: Block): boolean => { + // Only instanceable if it's a full cube block without complex geometry + if (!INSTANCEABLE_BLOCKS.has(block.name)) return false + + // Check if it's actually a full cube (no rotations, no complex models) + if (!block.models || block.models.length !== 1) return false + + const model = block.models[0][0] // First variant of first model + if (!model || model.x || model.y || model.z) return false // No rotations + + // Check if all elements are full cubes + return (model.elements ?? []).every(element => { + return element.from[0] === 0 && element.from[1] === 0 && element.from[2] === 0 && + element.to[0] === 16 && element.to[1] === 16 && element.to[2] === 16 + }) +} + let unknownBlockModel: BlockModelPartsResolved export function getSectionGeometry (sx: number, sy: number, sz: number, world: World) { let delayedRender = [] as Array<() => void> + // Check if instanced rendering is enabled + const enableInstancedRendering = world.config.useInstancedRendering + const attr: MesherGeometryOutput = { sx: sx + 8, sy: sy + 8, @@ -544,7 +754,8 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W // isFull: true, highestBlocks: new Map(), hadErrors: false, - blocksCount: 0 + blocksCount: 0, + instancedBlocks: {} } const cursor = new Vec3(0, 0, 0) @@ -621,6 +832,39 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W attr.blocksCount++ } if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) { + // Check if this block can use instanced rendering + if (enableInstancedRendering && isBlockInstanceable(block)) { + // Check if block should be culled (all faces hidden by neighbors) + if (shouldCullInstancedBlock(world, cursor, block)) { + // Block is completely surrounded, skip rendering + continue + } + + const blockKey = block.name + if (!attr.instancedBlocks[blockKey]) { + const textureInfo = getInstancedBlockTextureInfo(block) + attr.instancedBlocks[blockKey] = { + blockName: block.name, + stateId: block.stateId, + textureInfo, + positions: [] + } + } + attr.instancedBlocks[blockKey].positions.push({ + x: cursor.x, + y: cursor.y, + z: cursor.z + }) + attr.blocksCount++ + continue // Skip regular geometry generation for instanceable blocks + } + + // Skip buffer geometry generation if force instanced only mode is enabled + if (world.config.forceInstancedOnly) { + // In force instanced only mode, skip all non-instanceable blocks + continue + } + // cache let { models } = block diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 53e0c534..debf27a6 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -12,7 +12,11 @@ export const defaultMesherConfig = { // textureSize: 1024, // for testing debugModelVariant: undefined as undefined | number[], clipWorldBelowY: undefined as undefined | number, - disableSignsMapsSupport: false + disableSignsMapsSupport: false, + // Instanced rendering options + useInstancedRendering: false, + forceInstancedOnly: false, + enableSingleColorMode: false, } export type CustomBlockModels = { @@ -21,6 +25,18 @@ export type CustomBlockModels = { export type MesherConfig = typeof defaultMesherConfig +export type InstancedBlockEntry = { + blockName: string + stateId: number + textureInfo?: { + u: number + v: number + su: number + sv: number + } + positions: Array<{ x: number, y: number, z: number }> +} + export type MesherGeometryOutput = { sx: number, sy: number, @@ -46,6 +62,8 @@ export type MesherGeometryOutput = { hadErrors: boolean blocksCount: number customBlockModels?: CustomBlockModels + // New instanced blocks data + instancedBlocks: Record } export interface MesherMainEvents { diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index dfa4f43b..372d8211 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -55,7 +55,12 @@ export const defaultWorldRendererConfig = { enableDebugOverlay: false, _experimentalSmoothChunkLoading: true, _renderByChunks: false, - volume: 1 + volume: 1, + // New instancing options + useInstancedRendering: false, + forceInstancedOnly: false, + instancedOnlyDistance: 6, // chunks beyond this distance use instancing only + enableSingleColorMode: false, // ultra-performance mode with solid colors } export type WorldRendererConfig = typeof defaultWorldRendererConfig @@ -571,6 +576,10 @@ export abstract class WorldRendererCommon disableSignsMapsSupport: !this.worldRendererConfig.extraBlockRenderers, worldMinY: this.worldMinYRender, worldMaxY: this.worldMinYRender + this.worldSizeParams.worldHeight, + // Instanced rendering options + useInstancedRendering: this.worldRendererConfig.useInstancedRendering, + forceInstancedOnly: this.worldRendererConfig.forceInstancedOnly, + enableSingleColorMode: this.worldRendererConfig.enableSingleColorMode, } } diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts new file mode 100644 index 00000000..50ecb1c3 --- /dev/null +++ b/renderer/viewer/three/instancedRenderer.ts @@ -0,0 +1,494 @@ +import * as THREE from 'three' +import { Vec3 } from 'vec3' +import { WorldRendererThree } from './worldrendererThree' + +// Hardcoded list of full blocks that can use instancing +export const INSTANCEABLE_BLOCKS = new Set([ + 'grass_block', + 'dirt', + 'stone', + 'cobblestone', + 'mossy_cobblestone', + 'clay', + 'moss_block', + 'spawner', + 'sand', + 'gravel', + 'oak_planks', + 'birch_planks', + 'spruce_planks', + 'jungle_planks', + 'acacia_planks', + 'dark_oak_planks', + 'mangrove_planks', + 'cherry_planks', + 'bamboo_planks', + 'crimson_planks', + 'warped_planks', + 'iron_block', + 'gold_block', + 'diamond_block', + 'emerald_block', + 'netherite_block', + 'coal_block', + 'redstone_block', + 'lapis_block', + 'copper_block', + 'exposed_copper', + 'weathered_copper', + 'oxidized_copper', + 'cut_copper', + 'exposed_cut_copper', + 'weathered_cut_copper', + 'oxidized_cut_copper', + 'waxed_copper_block', + 'waxed_exposed_copper', + 'waxed_weathered_copper', + 'waxed_oxidized_copper', + 'raw_iron_block', + 'raw_copper_block', + 'raw_gold_block', + 'smooth_stone', + 'cobbled_deepslate', + 'deepslate', + 'calcite', + 'tuff', + 'dripstone_block', + 'amethyst_block', + 'budding_amethyst', + 'obsidian', + 'crying_obsidian', + 'bedrock', + 'end_stone', + 'purpur_block', + 'quartz_block', + 'smooth_quartz', + 'nether_bricks', + 'red_nether_bricks', + 'blackstone', + 'gilded_blackstone', + 'polished_blackstone', + 'chiseled_nether_bricks', + 'cracked_nether_bricks', + 'basalt', + 'smooth_basalt', + 'polished_basalt', + 'netherrack', + 'magma_block', + 'soul_sand', + 'soul_soil', + 'ancient_debris', + 'bone_block', + 'packed_ice', + 'blue_ice', + 'ice', + 'snow_block', + 'powder_snow', + 'white_wool', + 'orange_wool', + 'magenta_wool', + 'light_blue_wool', + 'yellow_wool', + 'lime_wool', + 'pink_wool', + 'gray_wool', + 'light_gray_wool', + 'cyan_wool', + 'purple_wool', + 'blue_wool', + 'brown_wool', + 'green_wool', + 'red_wool', + 'black_wool', + 'white_concrete', + 'orange_concrete', + 'magenta_concrete', + 'light_blue_concrete', + 'yellow_concrete', + 'lime_concrete', + 'pink_concrete', + 'gray_concrete', + 'light_gray_concrete', + 'cyan_concrete', + 'purple_concrete', + 'blue_concrete', + 'brown_concrete', + 'green_concrete', + 'red_concrete', + 'black_concrete', + 'white_terracotta', + 'orange_terracotta', + 'magenta_terracotta', + 'light_blue_terracotta', + 'yellow_terracotta', + 'lime_terracotta', + 'pink_terracotta', + 'gray_terracotta', + 'light_gray_terracotta', + 'cyan_terracotta', + 'purple_terracotta', + 'blue_terracotta', + 'brown_terracotta', + 'green_terracotta', + 'red_terracotta', + 'black_terracotta', + 'terracotta', + 'glazed_terracotta', +]) + +export interface InstancedBlockData { + positions: Vec3[] + blockName: string + stateId: number +} + +export interface InstancedSectionData { + sectionKey: string + instancedBlocks: Map + shouldUseInstancedOnly: boolean +} + +export class InstancedRenderer { + private readonly instancedMeshes = new Map() + private readonly blockCounts = new Map() + private readonly sectionInstances = new Map>() // sectionKey -> blockName -> instanceIndices + private readonly maxInstancesPerBlock = 100_000 // Reasonable limit + private readonly cubeGeometry: THREE.BoxGeometry + private readonly tempMatrix = new THREE.Matrix4() + + constructor (private readonly worldRenderer: WorldRendererThree) { + this.cubeGeometry = this.createCubeGeometry() + this.initInstancedMeshes() + } + + private createCubeGeometry (): THREE.BoxGeometry { + // Create a basic cube geometry + // For proper texturing, we would need to modify UV coordinates per block type + // For now, use default BoxGeometry which works with the texture atlas + const geometry = new THREE.BoxGeometry(1, 1, 1) + return geometry + } + + private createCustomGeometry (textureInfo: { u: number, v: number, su: number, sv: number }): THREE.BufferGeometry { + // Create custom geometry with specific UV coordinates for this block type + // This would be more complex but would give perfect texture mapping + const geometry = new THREE.BoxGeometry(1, 1, 1) + + // Get UV attribute + const uvAttribute = geometry.getAttribute('uv') as THREE.BufferAttribute + const uvs = uvAttribute.array as Float32Array + + // Modify UV coordinates to use the specific texture region + // This is a simplified version - real implementation would need to handle all 6 faces properly + for (let i = 0; i < uvs.length; i += 2) { + const u = uvs[i] + const v = uvs[i + 1] + + // Map from 0-1 to the specific texture region + uvs[i] = textureInfo.u + u * textureInfo.su + uvs[i + 1] = textureInfo.v + v * textureInfo.sv + } + + uvAttribute.needsUpdate = true + return geometry + } + + private initInstancedMeshes () { + // Create InstancedMesh for each instanceable block type + for (const blockName of INSTANCEABLE_BLOCKS) { + const material = this.createBlockMaterial(blockName) + const mesh = new THREE.InstancedMesh( + this.cubeGeometry, + material, + this.maxInstancesPerBlock + ) + mesh.name = `instanced_${blockName}` + mesh.frustumCulled = false // Important for performance + mesh.count = 0 // Start with 0 instances + + this.instancedMeshes.set(blockName, mesh) + this.worldRenderer.scene.add(mesh) + } + } + + private createBlockMaterial (blockName: string, textureInfo?: { u: number, v: number, su: number, sv: number }): THREE.Material { + const { enableSingleColorMode } = this.worldRenderer.worldRendererConfig + + if (enableSingleColorMode) { + // Ultra-performance mode: solid colors only + const color = this.getBlockColor(blockName) + return new THREE.MeshBasicMaterial({ color }) + } else { + // Use texture from the blocks atlas + if (this.worldRenderer.material.map) { + const material = this.worldRenderer.material.clone() + // The texture is already the correct blocks atlas texture + // Individual block textures are handled by UV coordinates in the geometry + return material + } else { + // Fallback to colored material + const color = this.getBlockColor(blockName) + return new THREE.MeshLambertMaterial({ color }) + } + } + } + + private getBlockColor (blockName: string): number { + // Simple color mapping for blocks + const colorMap: Record = { + 'grass_block': 0x7c_bd_3b, + 'dirt': 0x8b_45_13, + 'stone': 0x7d_7d_7d, + 'cobblestone': 0x7a_7a_7a, + 'mossy_cobblestone': 0x65_97_65, + 'clay': 0xa0_a5_a6, + 'moss_block': 0x58_7d_3c, + 'spawner': 0x1a_1a_1a, + 'sand': 0xf7_e9_a3, + 'gravel': 0x8a_8a_8a, + 'iron_block': 0xd8_d8_d8, + 'gold_block': 0xfa_ee_4d, + 'diamond_block': 0x5c_db_d5, + 'emerald_block': 0x17_dd_62, + 'coal_block': 0x34_34_34, + 'redstone_block': 0xa1_27_22, + 'lapis_block': 0x22_4d_bb, + 'netherrack': 0x72_3a_32, + 'obsidian': 0x0f_0f_23, + 'bedrock': 0x56_56_56, + 'end_stone': 0xda_dc_a6, + 'quartz_block': 0xec_e4_d6, + 'snow_block': 0xff_ff_ff, + 'ice': 0x9c_f4_ff, + 'packed_ice': 0x74_c7_ec, + 'blue_ice': 0x3e_5f_bc, + } + return colorMap[blockName] || 0x99_99_99 // Default gray + } + + handleInstancedGeometry (data: InstancedSectionData) { + const { sectionKey, instancedBlocks } = data + + // Clear previous instances for this section + this.clearSectionInstances(sectionKey) + + // Add new instances + for (const [blockName, blockData] of instancedBlocks) { + if (!INSTANCEABLE_BLOCKS.has(blockName)) continue + + const mesh = this.instancedMeshes.get(blockName) + if (!mesh) continue + + const currentCount = this.blockCounts.get(blockName) || 0 + let instanceIndex = currentCount + + for (const pos of blockData.positions) { + if (instanceIndex >= this.maxInstancesPerBlock) { + console.warn(`Exceeded max instances for block ${blockName} (${instanceIndex}/${this.maxInstancesPerBlock})`) + break + } + + this.tempMatrix.setPosition(pos.x, pos.y, pos.z) + mesh.setMatrixAt(instanceIndex, this.tempMatrix) + instanceIndex++ + } + + mesh.count = instanceIndex + mesh.instanceMatrix.needsUpdate = true + this.blockCounts.set(blockName, instanceIndex) + } + } + + handleInstancedBlocksFromWorker (instancedBlocks: Record, sectionKey: string) { + // Clear existing instances for this section first + this.removeSectionInstances(sectionKey) + + // Initialize section tracking if not exists + if (!this.sectionInstances.has(sectionKey)) { + this.sectionInstances.set(sectionKey, new Map()) + } + const sectionMap = this.sectionInstances.get(sectionKey)! + + for (const [blockName, blockData] of Object.entries(instancedBlocks)) { + if (!this.isBlockInstanceable(blockName)) continue + + let mesh = this.instancedMeshes.get(blockName) + if (!mesh) { + // Create new mesh if it doesn't exist + const material = this.createBlockMaterial(blockName, blockData.textureInfo) + mesh = new THREE.InstancedMesh( + this.cubeGeometry, + material, + this.maxInstancesPerBlock + ) + mesh.name = `instanced_${blockName}` + mesh.frustumCulled = false + mesh.count = 0 + this.instancedMeshes.set(blockName, mesh) + this.worldRenderer.scene.add(mesh) + } + + const instanceIndices: number[] = [] + const currentCount = this.blockCounts.get(blockName) || 0 + + // Add new instances for this section + for (const pos of blockData.positions) { + if (currentCount + instanceIndices.length >= this.maxInstancesPerBlock) { + console.warn(`Exceeded max instances for block ${blockName} (${currentCount + instanceIndices.length}/${this.maxInstancesPerBlock})`) + break + } + + const instanceIndex = currentCount + instanceIndices.length + this.tempMatrix.setPosition(pos.x, pos.y, pos.z) + mesh.setMatrixAt(instanceIndex, this.tempMatrix) + instanceIndices.push(instanceIndex) + } + + // Update tracking + if (instanceIndices.length > 0) { + sectionMap.set(blockName, instanceIndices) + this.blockCounts.set(blockName, currentCount + instanceIndices.length) + mesh.count = this.blockCounts.get(blockName) || 0 + mesh.instanceMatrix.needsUpdate = true + } + } + } + + private clearSectionInstances (sectionKey: string) { + // For now, we'll rebuild all instances each time + // This could be optimized to track instances per section + for (const [blockName, mesh] of this.instancedMeshes) { + mesh.count = 0 + mesh.instanceMatrix.needsUpdate = true + this.blockCounts.set(blockName, 0) + } + } + + removeSectionInstances (sectionKey: string) { + const sectionMap = this.sectionInstances.get(sectionKey) + if (!sectionMap) return // Section not tracked + + // Remove instances for each block type in this section + for (const [blockName, instanceIndices] of sectionMap) { + const mesh = this.instancedMeshes.get(blockName) + if (!mesh) continue + + // For efficiency, we'll rebuild the entire instance array by compacting it + // This removes gaps left by deleted instances + this.compactInstancesForBlock(blockName, instanceIndices) + } + + // Remove section from tracking + this.sectionInstances.delete(sectionKey) + } + + private compactInstancesForBlock (blockName: string, indicesToRemove: number[]) { + const mesh = this.instancedMeshes.get(blockName) + if (!mesh) return + + const currentCount = this.blockCounts.get(blockName) || 0 + const removeSet = new Set(indicesToRemove) + + let writeIndex = 0 + const tempMatrix = new THREE.Matrix4() + + // Compact the instance matrix by removing gaps + for (let readIndex = 0; readIndex < currentCount; readIndex++) { + if (!removeSet.has(readIndex)) { + if (writeIndex !== readIndex) { + mesh.getMatrixAt(readIndex, tempMatrix) + mesh.setMatrixAt(writeIndex, tempMatrix) + } + writeIndex++ + } + } + + // Update count and indices in section tracking + const newCount = writeIndex + this.blockCounts.set(blockName, newCount) + mesh.count = newCount + mesh.instanceMatrix.needsUpdate = true + + // Update all section tracking to reflect compacted indices + const offset = 0 + for (const [sectionKey, sectionMap] of this.sectionInstances) { + const sectionIndices = sectionMap.get(blockName) + if (sectionIndices) { + const compactedIndices = sectionIndices + .filter(index => !removeSet.has(index)) + .map(index => index - removeSet.size + offset) + + if (compactedIndices.length > 0) { + sectionMap.set(blockName, compactedIndices) + } else { + sectionMap.delete(blockName) + } + } + } + } + + shouldUseInstancedRendering (chunkKey: string): boolean { + const { useInstancedRendering, forceInstancedOnly, instancedOnlyDistance } = this.worldRenderer.worldRendererConfig + + if (!useInstancedRendering) return false + if (forceInstancedOnly) return true + + // Check distance for automatic switching + const [x, z] = chunkKey.split(',').map(Number) + const chunkPos = new Vec3(x * 16, 0, z * 16) + const [dx, dz] = this.worldRenderer.getDistance(chunkPos) + const maxDistance = Math.max(dx, dz) + + return maxDistance >= instancedOnlyDistance + } + + isBlockInstanceable (blockName: string): boolean { + return INSTANCEABLE_BLOCKS.has(blockName) + } + + updateMaterials () { + // Update materials when texture atlas changes + for (const [blockName, mesh] of this.instancedMeshes) { + const newMaterial = this.createBlockMaterial(blockName) + const oldMaterial = mesh.material + mesh.material = newMaterial + if (oldMaterial instanceof THREE.Material) { + oldMaterial.dispose() + } + } + } + + destroy () { + // Clean up resources + for (const [blockName, mesh] of this.instancedMeshes) { + this.worldRenderer.scene.remove(mesh) + mesh.geometry.dispose() + if (mesh.material instanceof THREE.Material) { + mesh.material.dispose() + } + } + this.instancedMeshes.clear() + this.blockCounts.clear() + this.sectionInstances.clear() + this.cubeGeometry.dispose() + } + + getStats () { + let totalInstances = 0 + let activeBlockTypes = 0 + + for (const [blockName, mesh] of this.instancedMeshes) { + if (mesh.count > 0) { + totalInstances += mesh.count + activeBlockTypes++ + } + } + + return { + totalInstances, + activeBlockTypes, + drawCalls: activeBlockTypes, // One draw call per active block type + memoryEstimate: totalInstances * 64 // Rough estimate in bytes + } + } +} diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index b4ae4961..552aa2c1 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -24,6 +24,7 @@ import { ThreeJsSound } from './threeJsSound' import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' import { Fountain } from './threeJsParticles' +import { InstancedRenderer, INSTANCEABLE_BLOCKS } from './instancedRenderer' type SectionKey = string @@ -48,6 +49,7 @@ export class WorldRendererThree extends WorldRendererCommon { cameraShake: CameraShake cameraContainer: THREE.Object3D media: ThreeJsMedia + instancedRenderer: InstancedRenderer waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] } camera: THREE.PerspectiveCamera renderTimeAvg = 0 @@ -100,6 +102,7 @@ export class WorldRendererThree extends WorldRendererCommon { this.soundSystem = new ThreeJsSound(this) this.cameraShake = new CameraShake(this, this.onRender) this.media = new ThreeJsMedia(this) + this.instancedRenderer = new InstancedRenderer(this) // this.fountain = new Fountain(this.scene, this.scene, { // position: new THREE.Vector3(0, 10, 0), // }) @@ -227,6 +230,8 @@ export class WorldRendererThree extends WorldRendererCommon { await super.updateAssetsData() this.onAllTexturesLoaded() + // Update instanced renderer materials when textures change + this.instancedRenderer.updateMaterials() if (Object.keys(this.loadedChunks).length > 0) { console.log('rerendering chunks because of texture update') this.rerenderAllChunks() @@ -295,12 +300,15 @@ export class WorldRendererThree extends WorldRendererCommon { const formatBigNumber = (num: number) => { return new Intl.NumberFormat('en-US', {}).format(num) } + const instancedStats = this.instancedRenderer.getStats() let text = '' text += `C: ${formatBigNumber(this.renderer.info.render.calls)} ` text += `TR: ${formatBigNumber(this.renderer.info.render.triangles)} ` text += `TE: ${formatBigNumber(this.renderer.info.memory.textures)} ` text += `F: ${formatBigNumber(this.tilesRendered)} ` - text += `B: ${formatBigNumber(this.blocksRendered)}` + text += `B: ${formatBigNumber(this.blocksRendered)} ` + text += `I: ${formatBigNumber(instancedStats.totalInstances)}/${instancedStats.activeBlockTypes}t ` + text += `DC: ${formatBigNumber(instancedStats.drawCalls)}` pane.updateText(text) this.backendInfoReport = text } @@ -343,8 +351,21 @@ export class WorldRendererThree extends WorldRendererCommon { } // debugRecomputedDeletedObjects = 0 + handleInstancedBlocks (instancedBlocks: Record, sectionKey: string): void { + this.instancedRenderer.handleInstancedBlocksFromWorker(instancedBlocks, sectionKey) + } + handleWorkerMessage (data: { geometry: MesherGeometryOutput, key, type }): void { if (data.type !== 'geometry') return + + const chunkCoords = data.key.split(',') + const chunkKey = chunkCoords[0] + ',' + chunkCoords[2] + + // Handle instanced blocks data from worker + if (data.geometry.instancedBlocks && Object.keys(data.geometry.instancedBlocks).length > 0) { + this.handleInstancedBlocks(data.geometry.instancedBlocks, data.key) + } + let object: THREE.Object3D = this.sectionObjects[data.key] if (object) { this.scene.remove(object) @@ -352,8 +373,7 @@ export class WorldRendererThree extends WorldRendererCommon { delete this.sectionObjects[data.key] } - const chunkCoords = data.key.split(',') - if (!this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]] || !data.geometry.positions.length || !this.active) return + if (!this.loadedChunks[chunkKey] || !data.geometry.positions.length || !this.active) return // if (object) { // this.debugRecomputedDeletedObjects++ @@ -878,6 +898,10 @@ export class WorldRendererThree extends WorldRendererCommon { for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += 16) { this.setSectionDirty(new Vec3(x, y, z), false) const key = `${x},${y},${z}` + + // Remove instanced blocks for this section + this.instancedRenderer.removeSectionInstances(key) + const mesh = this.sectionObjects[key] if (mesh) { this.scene.remove(mesh) @@ -907,6 +931,7 @@ export class WorldRendererThree extends WorldRendererCommon { } destroy (): void { + this.instancedRenderer.destroy() super.destroy() } diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 882610f8..b61cb1b6 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -52,6 +52,11 @@ const defaultOptions = { starfieldRendering: true, enabledResourcepack: null as string | null, useVersionsTextures: 'latest', + // Instanced rendering options + useInstancedRendering: false, + forceInstancedOnly: false, + instancedOnlyDistance: 6, + enableSingleColorMode: false, serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never', showHand: true, viewBobbing: true, diff --git a/src/react/RendererDebugMenu.tsx b/src/react/RendererDebugMenu.tsx index f4bf7876..b116bca0 100644 --- a/src/react/RendererDebugMenu.tsx +++ b/src/react/RendererDebugMenu.tsx @@ -16,7 +16,7 @@ const RendererDebugMenu = ({ worldRenderer }: { worldRenderer: WorldRendererComm const { reactiveDebugParams } = worldRenderer const { chunksRenderAboveEnabled, chunksRenderBelowEnabled, chunksRenderDistanceEnabled, chunksRenderAboveOverride, chunksRenderBelowOverride, chunksRenderDistanceOverride, stopRendering, disableEntities } = useSnapshot(reactiveDebugParams) - const { rendererPerfDebugOverlay } = useSnapshot(options) + const { rendererPerfDebugOverlay, useInstancedRendering, forceInstancedOnly, instancedOnlyDistance, enableSingleColorMode } = useSnapshot(options) // Helper to round values to nearest step const roundToStep = (value: number, step: number) => Math.round(value / step) * step @@ -115,5 +115,36 @@ const RendererDebugMenu = ({ worldRenderer }: { worldRenderer: WorldRendererComm /> */} + +
+

Instanced Rendering

+
} diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 478da4fb..8d1e46b9 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -115,6 +115,14 @@ export const watchOptionsAfterViewerInit = () => { watchValue(options, o => { // appViewer.inWorldRenderingConfig.neighborChunkUpdates = o.neighborChunkUpdates }) + + // Instanced rendering options + watchValue(options, o => { + appViewer.inWorldRenderingConfig.useInstancedRendering = o.useInstancedRendering + appViewer.inWorldRenderingConfig.forceInstancedOnly = o.forceInstancedOnly + appViewer.inWorldRenderingConfig.instancedOnlyDistance = o.instancedOnlyDistance + appViewer.inWorldRenderingConfig.enableSingleColorMode = o.enableSingleColorMode + }) } export const watchOptionsAfterWorldViewInit = (worldView: WorldDataEmitter) => { From 4aebfecf69cde48a76e331b9ac33791ebb985cfe Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 29 Jun 2025 15:22:21 +0300 Subject: [PATCH 02/86] some progress --- renderer/viewer/lib/mesher/mesher.ts | 18 +-- renderer/viewer/lib/mesher/models.ts | 67 +++++---- renderer/viewer/lib/mesher/shared.ts | 17 ++- renderer/viewer/lib/mesher/world.ts | 8 +- renderer/viewer/lib/worldrendererCommon.ts | 13 +- renderer/viewer/three/instancedRenderer.ts | 143 +++++++++++--------- renderer/viewer/three/worldrendererThree.ts | 14 +- 7 files changed, 167 insertions(+), 113 deletions(-) diff --git a/renderer/viewer/lib/mesher/mesher.ts b/renderer/viewer/lib/mesher/mesher.ts index a063d77f..444093f2 100644 --- a/renderer/viewer/lib/mesher/mesher.ts +++ b/renderer/viewer/lib/mesher/mesher.ts @@ -1,7 +1,7 @@ import { Vec3 } from 'vec3' import { World } from './world' import { getSectionGeometry, setBlockStatesData as setMesherData } from './models' -import { BlockStateModelInfo } from './shared' +import { BlockStateModelInfo, InstancingMode } from './shared' import { INVISIBLE_BLOCKS } from './worldConstants' globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value)) @@ -17,7 +17,7 @@ if (module.require) { let workerIndex = 0 let world: World -let dirtySections = new Map() +let dirtySections = new Map() let allDataReady = false function sectionKey (x, y, z) { @@ -47,7 +47,7 @@ function drainQueue (from, to) { queuedMessages = queuedMessages.slice(to) } -function setSectionDirty (pos, value = true) { +function setSectionDirty (pos, value = true, instancingMode = InstancingMode.None) { const x = Math.floor(pos.x / 16) * 16 const y = Math.floor(pos.y / 16) * 16 const z = Math.floor(pos.z / 16) * 16 @@ -60,7 +60,7 @@ function setSectionDirty (pos, value = true) { const chunk = world.getColumn(x, z) if (chunk?.getSection(pos)) { - dirtySections.set(key, (dirtySections.get(key) || 0) + 1) + dirtySections.set(key, { key, instancingMode, times: (dirtySections.get(key)?.times || 0) + 1 }) } else { postMessage({ type: 'sectionFinished', key, workerIndex }) } @@ -98,12 +98,14 @@ const handleMessage = data => { setMesherData(data.blockstatesModels, data.blocksAtlas, data.config.outputFormat === 'webgpu') allDataReady = true workerIndex = data.workerIndex + world.instancedBlocks = data.instancedBlocks + world.instancedBlockIds = data.instancedBlockIds || new Map() break } case 'dirty': { const loc = new Vec3(data.x, data.y, data.z) - setSectionDirty(loc, data.value) + setSectionDirty(loc, data.value, data.instancingMode || InstancingMode.None) break } @@ -197,13 +199,13 @@ setInterval(() => { // console.log(sections.length + ' dirty sections') // const start = performance.now() - for (const key of dirtySections.keys()) { + for (const [key, { instancingMode }] of dirtySections.entries()) { const [x, y, z] = key.split(',').map(v => parseInt(v, 10)) const chunk = world.getColumn(x, z) let processTime = 0 if (chunk?.getSection(new Vec3(x, y, z))) { const start = performance.now() - const geometry = getSectionGeometry(x, y, z, world) + const geometry = getSectionGeometry(x, y, z, world, instancingMode) const transferable = [geometry.positions?.buffer, geometry.normals?.buffer, geometry.colors?.buffer, geometry.uvs?.buffer].filter(Boolean) //@ts-expect-error postMessage({ type: 'geometry', key, geometry, workerIndex }, transferable) @@ -213,7 +215,7 @@ setInterval(() => { } const dirtyTimes = dirtySections.get(key) if (!dirtyTimes) throw new Error('dirtySections.get(key) is falsy') - for (let i = 0; i < dirtyTimes; i++) { + for (let i = 0; i < dirtyTimes.times; i++) { postMessage({ type: 'sectionFinished', key, workerIndex, processTime }) processTime = 0 } diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index 9e928ccb..f83f8c25 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -5,7 +5,7 @@ import { BlockType } from '../../../playground/shared' import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock } from './world' import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon' import { INVISIBLE_BLOCKS } from './worldConstants' -import { MesherGeometryOutput, HighestBlockInfo, InstancedBlockEntry } from './shared' +import { MesherGeometryOutput, HighestBlockInfo, InstancedBlockEntry, InstancingMode } from './shared' // Hardcoded list of full blocks that can use instancing export const INSTANCEABLE_BLOCKS = new Set([ @@ -146,6 +146,9 @@ let blockProvider: WorldBlockProvider const tints: any = {} let needTiles = false +// Cache for texture info to avoid repeated calculations +const textureInfoCache = new Map() + let tintsData try { tintsData = require('esbuild-data').tints @@ -652,31 +655,29 @@ const isBlockWaterlogged = (block: Block) => { } const shouldCullInstancedBlock = (world: World, cursor: Vec3, block: Block): boolean => { + // Early return for blocks that should never be culled + if (block.transparent) return false + // Check if all 6 faces would be culled (hidden by neighbors) const cullIfIdentical = block.name.includes('glass') || block.name.includes('ice') + // Cache cursor offset to avoid creating new Vec3 instances + const offsetCursor = new Vec3(0, 0, 0) + // eslint-disable-next-line guard-for-in for (const face in elemFaces) { const { dir } = elemFaces[face] - const neighbor = world.getBlock(cursor.plus(new Vec3(dir[0], dir[1], dir[2])), blockProvider, {}) + offsetCursor.set(cursor.x + dir[0], cursor.y + dir[1], cursor.z + dir[2]) + const neighbor = world.getBlock(offsetCursor, blockProvider, {}) - if (!neighbor) { - // Face is exposed to air/void - return false - } + // Face is exposed to air/void - block must be rendered + if (!neighbor) return false - if (cullIfIdentical && neighbor.stateId === block.stateId) { - // Same block type, face should be culled - continue - } + // Handle special case for identical blocks (glass/ice) + if (cullIfIdentical && neighbor.stateId === block.stateId) continue - if (!neighbor.transparent && isCube(neighbor)) { - // Neighbor is opaque and full cube, face should be culled - continue - } - - // Face is not culled, block should be rendered - return false + // If neighbor is not a full opaque cube, face is visible + if (neighbor.transparent || !isCube(neighbor)) return false } // All faces are culled, block should not be rendered @@ -684,6 +685,12 @@ const shouldCullInstancedBlock = (world: World, cursor: Vec3, block: Block): boo } const getInstancedBlockTextureInfo = (block: Block) => { + // Cache texture info by block state ID + const cacheKey = block.stateId + if (textureInfoCache.has(cacheKey)) { + return textureInfoCache.get(cacheKey) + } + // Get texture info from the first available face const model = block.models?.[0]?.[0] if (!model?.elements?.[0]) return undefined @@ -692,20 +699,25 @@ const getInstancedBlockTextureInfo = (block: Block) => { // Try to find a face with texture - prefer visible faces const faceOrder = ['up', 'north', 'east', 'south', 'west', 'down'] + let textureInfo: any + for (const faceName of faceOrder) { const face = element.faces[faceName] if (face?.texture) { const texture = face.texture as any - return { + textureInfo = { u: texture.u || 0, v: texture.v || 0, su: texture.su || 1, sv: texture.sv || 1 } + break } } - return undefined + // Cache the result + textureInfoCache.set(cacheKey, textureInfo) + return textureInfo } const isBlockInstanceable = (block: Block): boolean => { @@ -726,11 +738,12 @@ const isBlockInstanceable = (block: Block): boolean => { } let unknownBlockModel: BlockModelPartsResolved -export function getSectionGeometry (sx: number, sy: number, sz: number, world: World) { +export function getSectionGeometry (sx: number, sy: number, sz: number, world: World, instancingMode = InstancingMode.None): MesherGeometryOutput { let delayedRender = [] as Array<() => void> - // Check if instanced rendering is enabled - const enableInstancedRendering = world.config.useInstancedRendering + // Check if instanced rendering is enabled for this section + const enableInstancedRendering = instancingMode !== InstancingMode.None + const forceInstancedOnly = instancingMode === InstancingMode.TexturedInstancing // Only force when using full instancing const attr: MesherGeometryOutput = { sx: sx + 8, @@ -843,7 +856,15 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W const blockKey = block.name if (!attr.instancedBlocks[blockKey]) { const textureInfo = getInstancedBlockTextureInfo(block) + // Get or create block ID + let blockId = world.instancedBlockIds.get(block.name) + if (blockId === undefined) { + blockId = world.instancedBlockIds.size + world.instancedBlockIds.set(block.name, blockId) + } + attr.instancedBlocks[blockKey] = { + blockId, blockName: block.name, stateId: block.stateId, textureInfo, @@ -860,7 +881,7 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W } // Skip buffer geometry generation if force instanced only mode is enabled - if (world.config.forceInstancedOnly) { + if (forceInstancedOnly) { // In force instanced only mode, skip all non-instanceable blocks continue } diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index debf27a6..b2881e8d 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -1,5 +1,11 @@ import { BlockType } from '../../../playground/shared' +export enum InstancingMode { + None = 'none', + ColorOnly = 'color_only', + TexturedInstancing = 'textured_instancing' +} + // only here for easier testing export const defaultMesherConfig = { version: '', @@ -13,10 +19,6 @@ export const defaultMesherConfig = { debugModelVariant: undefined as undefined | number[], clipWorldBelowY: undefined as undefined | number, disableSignsMapsSupport: false, - // Instanced rendering options - useInstancedRendering: false, - forceInstancedOnly: false, - enableSingleColorMode: false, } export type CustomBlockModels = { @@ -26,6 +28,7 @@ export type CustomBlockModels = { export type MesherConfig = typeof defaultMesherConfig export type InstancedBlockEntry = { + blockId: number // Unique ID for this block type blockName: string stateId: number textureInfo?: { @@ -37,6 +40,12 @@ export type InstancedBlockEntry = { positions: Array<{ x: number, y: number, z: number }> } +export type InstancingMesherData = { + blocks: { + [stateId: number]: number // instance id + } +} + export type MesherGeometryOutput = { sx: number, sy: number, diff --git a/renderer/viewer/lib/mesher/world.ts b/renderer/viewer/lib/mesher/world.ts index f2757ae6..110fb93c 100644 --- a/renderer/viewer/lib/mesher/world.ts +++ b/renderer/viewer/lib/mesher/world.ts @@ -5,7 +5,7 @@ import { Vec3 } from 'vec3' import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider' import moreBlockDataGeneratedJson from '../moreBlockDataGenerated.json' import legacyJson from '../../../../src/preflatMap.json' -import { defaultMesherConfig, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey } from './shared' +import { defaultMesherConfig, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig } from './shared' import { INVISIBLE_BLOCKS } from './worldConstants' const ignoreAoBlocks = Object.keys(moreBlockDataGeneratedJson.noOcclusions) @@ -42,12 +42,14 @@ export class World { customBlockModels = new Map() // chunkKey -> blockModels sentBlockStateModels = new Set() blockStateModelInfo = new Map() + instancedBlocks: Record = {} + instancedBlockIds = new Map() - constructor (version) { + constructor (version: string) { this.Chunk = Chunks(version) as any this.biomeCache = mcData(version).biomes this.preflat = !mcData(version).supportFeature('blockStateId') - this.config.version = version + this.config = { ...defaultMesherConfig, version } } getLight (pos: Vec3, isNeighbor = false, skipMoreChecks = false, curBlockName = '') { diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index 372d8211..30761aaf 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -12,7 +12,7 @@ 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, InstancingMode } from './mesher/shared' import { chunkPos } from './simpleUtils' import { addNewStat, removeAllStats, updatePanesVisibility, updateStatText } from './ui/newStats' import { WorldDataEmitterWorker } from './worldDataEmitter' @@ -576,10 +576,6 @@ export abstract class WorldRendererCommon disableSignsMapsSupport: !this.worldRendererConfig.extraBlockRenderers, worldMinY: this.worldMinYRender, worldMaxY: this.worldMinYRender + this.worldSizeParams.worldHeight, - // Instanced rendering options - useInstancedRendering: this.worldRendererConfig.useInstancedRendering, - forceInstancedOnly: this.worldRendererConfig.forceInstancedOnly, - enableSingleColorMode: this.worldRendererConfig.enableSingleColorMode, } } @@ -927,7 +923,7 @@ export abstract class WorldRendererCommon return Promise.all(data) } - setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks + setSectionDirty (pos: Vec3, value = true, useChangeWorker = false, instancingMode = InstancingMode.None) { // value false is used for unloading chunks if (!this.forceCallFromMesherReplayer && this.mesherLogReader) return if (this.viewDistance === -1) throw new Error('viewDistance not set') @@ -941,7 +937,7 @@ export abstract class WorldRendererCommon // Dispatch sections to workers based on position // This guarantees uniformity accross workers and that a given section // is always dispatched to the same worker - const hash = this.getWorkerNumber(pos, useChangeWorker && this.mesherLogger.active) + const hash = this.getWorkerNumber(pos, this.mesherLogger.active) this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1) if (this.forceCallFromMesherReplayer) { this.workers[hash].postMessage({ @@ -950,17 +946,18 @@ export abstract class WorldRendererCommon y: pos.y, z: pos.z, value, + instancingMode, config: this.getMesherConfig(), }) } else { this.toWorkerMessagesQueue[hash] ??= [] this.toWorkerMessagesQueue[hash].push({ - // this.workers[hash].postMessage({ type: 'dirty', x: pos.x, y: pos.y, z: pos.z, value, + instancingMode, config: this.getMesherConfig(), }) this.dispatchMessages() diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index 50ecb1c3..105cd3bb 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -1,5 +1,6 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' +import moreBlockData from '../lib/moreBlockDataGenerated.json' import { WorldRendererThree } from './worldrendererThree' // Hardcoded list of full blocks that can use instancing @@ -136,7 +137,20 @@ export const INSTANCEABLE_BLOCKS = new Set([ 'glazed_terracotta', ]) +// Helper function to parse RGB color strings from moreBlockDataGenerated.json +function parseRgbColor (rgbString: string): number { + const match = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(rgbString) + if (!match) return 0x99_99_99 // Default gray + + const r = parseInt(match[1], 10) + const g = parseInt(match[2], 10) + const b = parseInt(match[3], 10) + + return (r << 16) | (g << 8) | b +} + export interface InstancedBlockData { + blockId: number positions: Vec3[] blockName: string stateId: number @@ -149,12 +163,15 @@ export interface InstancedSectionData { } export class InstancedRenderer { - private readonly instancedMeshes = new Map() - private readonly blockCounts = new Map() - private readonly sectionInstances = new Map>() // sectionKey -> blockName -> instanceIndices - private readonly maxInstancesPerBlock = 100_000 // Reasonable limit + private readonly instancedMeshes = new Map() + private readonly blockCounts = new Map() + private readonly sectionInstances = new Map>() + private readonly maxInstancesPerBlock = 100_000 private readonly cubeGeometry: THREE.BoxGeometry private readonly tempMatrix = new THREE.Matrix4() + private readonly blockIdToName = new Map() + private readonly blockNameToId = new Map() + private nextBlockId = 0 constructor (private readonly worldRenderer: WorldRendererThree) { this.cubeGeometry = this.createCubeGeometry() @@ -206,7 +223,7 @@ export class InstancedRenderer { mesh.frustumCulled = false // Important for performance mesh.count = 0 // Start with 0 instances - this.instancedMeshes.set(blockName, mesh) + this.instancedMeshes.set(this.getBlockId(blockName), mesh) this.worldRenderer.scene.add(mesh) } } @@ -220,6 +237,7 @@ export class InstancedRenderer { return new THREE.MeshBasicMaterial({ color }) } else { // Use texture from the blocks atlas + // eslint-disable-next-line no-lonely-if if (this.worldRenderer.material.map) { const material = this.worldRenderer.material.clone() // The texture is already the correct blocks atlas texture @@ -234,36 +252,14 @@ export class InstancedRenderer { } private getBlockColor (blockName: string): number { - // Simple color mapping for blocks - const colorMap: Record = { - 'grass_block': 0x7c_bd_3b, - 'dirt': 0x8b_45_13, - 'stone': 0x7d_7d_7d, - 'cobblestone': 0x7a_7a_7a, - 'mossy_cobblestone': 0x65_97_65, - 'clay': 0xa0_a5_a6, - 'moss_block': 0x58_7d_3c, - 'spawner': 0x1a_1a_1a, - 'sand': 0xf7_e9_a3, - 'gravel': 0x8a_8a_8a, - 'iron_block': 0xd8_d8_d8, - 'gold_block': 0xfa_ee_4d, - 'diamond_block': 0x5c_db_d5, - 'emerald_block': 0x17_dd_62, - 'coal_block': 0x34_34_34, - 'redstone_block': 0xa1_27_22, - 'lapis_block': 0x22_4d_bb, - 'netherrack': 0x72_3a_32, - 'obsidian': 0x0f_0f_23, - 'bedrock': 0x56_56_56, - 'end_stone': 0xda_dc_a6, - 'quartz_block': 0xec_e4_d6, - 'snow_block': 0xff_ff_ff, - 'ice': 0x9c_f4_ff, - 'packed_ice': 0x74_c7_ec, - 'blue_ice': 0x3e_5f_bc, + // Get color from moreBlockDataGenerated.json + const rgbString = moreBlockData.colors[blockName] + if (rgbString) { + return parseRgbColor(rgbString) } - return colorMap[blockName] || 0x99_99_99 // Default gray + + // Fallback to default gray if color not found + return 0x99_99_99 } handleInstancedGeometry (data: InstancedSectionData) { @@ -276,10 +272,10 @@ export class InstancedRenderer { for (const [blockName, blockData] of instancedBlocks) { if (!INSTANCEABLE_BLOCKS.has(blockName)) continue - const mesh = this.instancedMeshes.get(blockName) + const mesh = this.instancedMeshes.get(this.getBlockId(blockName)) if (!mesh) continue - const currentCount = this.blockCounts.get(blockName) || 0 + const currentCount = this.blockCounts.get(this.getBlockId(blockName)) || 0 let instanceIndex = currentCount for (const pos of blockData.positions) { @@ -295,7 +291,7 @@ export class InstancedRenderer { mesh.count = instanceIndex mesh.instanceMatrix.needsUpdate = true - this.blockCounts.set(blockName, instanceIndex) + this.blockCounts.set(this.getBlockId(blockName), instanceIndex) } } @@ -312,7 +308,10 @@ export class InstancedRenderer { for (const [blockName, blockData] of Object.entries(instancedBlocks)) { if (!this.isBlockInstanceable(blockName)) continue - let mesh = this.instancedMeshes.get(blockName) + const { blockId } = blockData + this.blockIdToName.set(blockId, blockName) + + let mesh = this.instancedMeshes.get(blockId) if (!mesh) { // Create new mesh if it doesn't exist const material = this.createBlockMaterial(blockName, blockData.textureInfo) @@ -324,12 +323,12 @@ export class InstancedRenderer { mesh.name = `instanced_${blockName}` mesh.frustumCulled = false mesh.count = 0 - this.instancedMeshes.set(blockName, mesh) + this.instancedMeshes.set(blockId, mesh) this.worldRenderer.scene.add(mesh) } const instanceIndices: number[] = [] - const currentCount = this.blockCounts.get(blockName) || 0 + const currentCount = this.blockCounts.get(blockId) || 0 // Add new instances for this section for (const pos of blockData.positions) { @@ -346,9 +345,9 @@ export class InstancedRenderer { // Update tracking if (instanceIndices.length > 0) { - sectionMap.set(blockName, instanceIndices) - this.blockCounts.set(blockName, currentCount + instanceIndices.length) - mesh.count = this.blockCounts.get(blockName) || 0 + sectionMap.set(blockId, instanceIndices) + this.blockCounts.set(blockId, currentCount + instanceIndices.length) + mesh.count = this.blockCounts.get(blockId) || 0 mesh.instanceMatrix.needsUpdate = true } } @@ -357,10 +356,10 @@ export class InstancedRenderer { private clearSectionInstances (sectionKey: string) { // For now, we'll rebuild all instances each time // This could be optimized to track instances per section - for (const [blockName, mesh] of this.instancedMeshes) { + for (const [blockId, mesh] of this.instancedMeshes) { mesh.count = 0 mesh.instanceMatrix.needsUpdate = true - this.blockCounts.set(blockName, 0) + this.blockCounts.set(blockId, 0) } } @@ -369,24 +368,24 @@ export class InstancedRenderer { if (!sectionMap) return // Section not tracked // Remove instances for each block type in this section - for (const [blockName, instanceIndices] of sectionMap) { - const mesh = this.instancedMeshes.get(blockName) + for (const [blockId, instanceIndices] of sectionMap) { + const mesh = this.instancedMeshes.get(blockId) if (!mesh) continue // For efficiency, we'll rebuild the entire instance array by compacting it // This removes gaps left by deleted instances - this.compactInstancesForBlock(blockName, instanceIndices) + this.compactInstancesForBlock(blockId, instanceIndices) } // Remove section from tracking this.sectionInstances.delete(sectionKey) } - private compactInstancesForBlock (blockName: string, indicesToRemove: number[]) { - const mesh = this.instancedMeshes.get(blockName) + private compactInstancesForBlock (blockId: number, indicesToRemove: number[]) { + const mesh = this.instancedMeshes.get(blockId) if (!mesh) return - const currentCount = this.blockCounts.get(blockName) || 0 + const currentCount = this.blockCounts.get(blockId) || 0 const removeSet = new Set(indicesToRemove) let writeIndex = 0 @@ -405,23 +404,23 @@ export class InstancedRenderer { // Update count and indices in section tracking const newCount = writeIndex - this.blockCounts.set(blockName, newCount) + this.blockCounts.set(blockId, newCount) mesh.count = newCount mesh.instanceMatrix.needsUpdate = true // Update all section tracking to reflect compacted indices const offset = 0 for (const [sectionKey, sectionMap] of this.sectionInstances) { - const sectionIndices = sectionMap.get(blockName) + const sectionIndices = sectionMap.get(blockId) if (sectionIndices) { const compactedIndices = sectionIndices .filter(index => !removeSet.has(index)) .map(index => index - removeSet.size + offset) if (compactedIndices.length > 0) { - sectionMap.set(blockName, compactedIndices) + sectionMap.set(blockId, compactedIndices) } else { - sectionMap.delete(blockName) + sectionMap.delete(blockId) } } } @@ -448,19 +447,22 @@ export class InstancedRenderer { updateMaterials () { // Update materials when texture atlas changes - for (const [blockName, mesh] of this.instancedMeshes) { - const newMaterial = this.createBlockMaterial(blockName) - const oldMaterial = mesh.material - mesh.material = newMaterial - if (oldMaterial instanceof THREE.Material) { - oldMaterial.dispose() + for (const [blockId, mesh] of this.instancedMeshes) { + const blockName = this.blockIdToName.get(blockId) + if (blockName) { + const newMaterial = this.createBlockMaterial(blockName) + const oldMaterial = mesh.material + mesh.material = newMaterial + if (oldMaterial instanceof THREE.Material) { + oldMaterial.dispose() + } } } } destroy () { // Clean up resources - for (const [blockName, mesh] of this.instancedMeshes) { + for (const [blockId, mesh] of this.instancedMeshes) { this.worldRenderer.scene.remove(mesh) mesh.geometry.dispose() if (mesh.material instanceof THREE.Material) { @@ -470,6 +472,9 @@ export class InstancedRenderer { this.instancedMeshes.clear() this.blockCounts.clear() this.sectionInstances.clear() + this.blockIdToName.clear() + this.blockNameToId.clear() + this.nextBlockId = 0 this.cubeGeometry.dispose() } @@ -477,7 +482,7 @@ export class InstancedRenderer { let totalInstances = 0 let activeBlockTypes = 0 - for (const [blockName, mesh] of this.instancedMeshes) { + for (const [blockId, mesh] of this.instancedMeshes) { if (mesh.count > 0) { totalInstances += mesh.count activeBlockTypes++ @@ -491,4 +496,16 @@ export class InstancedRenderer { memoryEstimate: totalInstances * 64 // Rough estimate in bytes } } + + private getBlockId (blockName: string): number { + // Get the block ID from our local map + let blockId = this.blockNameToId.get(blockName) + if (blockId === undefined) { + // If the block ID doesn't exist, create a new one + blockId = this.nextBlockId++ + this.blockNameToId.set(blockName, blockId) + this.blockIdToName.set(blockId, blockName) + } + return blockId + } } diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 552aa2c1..a989c5d2 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -8,7 +8,7 @@ import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer import { chunkPos, sectionPos } from '../lib/simpleUtils' import { WorldRendererCommon } from '../lib/worldrendererCommon' import { addNewStat } from '../lib/ui/newStats' -import { MesherGeometryOutput } from '../lib/mesher/shared' +import { MesherGeometryOutput, InstancingMode } from '../lib/mesher/shared' import { ItemSpecificContextProperties } from '../lib/basePlayerState' import { getMyHand } from '../lib/hand' import { setBlockPosition } from '../lib/mesher/standaloneRenderer' @@ -911,10 +911,16 @@ export class WorldRendererThree extends WorldRendererCommon { } } - setSectionDirty (...args: Parameters) { - const [pos] = args + setSectionDirty (pos: Vec3, value = true) { + const { useInstancedRendering, enableSingleColorMode } = this.worldRendererConfig + let instancingMode = InstancingMode.None + + if (useInstancedRendering) { + instancingMode = enableSingleColorMode ? InstancingMode.ColorOnly : InstancingMode.TexturedInstancing + } + this.cleanChunkTextures(pos.x, pos.z) // todo don't do this! - super.setSectionDirty(...args) + super.setSectionDirty(pos, value, undefined, instancingMode) } static getRendererInfo (renderer: THREE.WebGLRenderer) { From 7a692ac210e53eedb3a6de19b61ed55fcf14264e Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 29 Jun 2025 15:22:58 +0300 Subject: [PATCH 03/86] f --- src/defaultOptions.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index d2d510ec..be5851b2 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -42,6 +42,11 @@ export const defaultOptions = { starfieldRendering: true, enabledResourcepack: null as string | null, useVersionsTextures: 'latest', + // Instanced rendering options + useInstancedRendering: false, + forceInstancedOnly: false, + instancedOnlyDistance: 6, + enableSingleColorMode: false, serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never', showHand: true, viewBobbing: true, From 561a18527fe4cbb5dff904ee11d3ed460f7558e4 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 12 Jul 2025 05:38:12 +0300 Subject: [PATCH 04/86] add creative server --- config.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config.json b/config.json index 57b1c207..940fb738 100644 --- a/config.json +++ b/config.json @@ -16,6 +16,10 @@ { "ip": "wss://play2.mcraft.fun" }, + { + "ip": "wss://play-creative.mcraft.fun", + "description": "Might be available soon, stay tuned!" + }, { "ip": "kaboom.pw", "version": "1.20.3", From a19d459e8a7057e91b48b42a1ea7ebaebcb52d24 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 02:16:17 +0300 Subject: [PATCH 05/86] rm blocks hardcode --- renderer/viewer/lib/mesher/mesher.ts | 2 +- renderer/viewer/lib/mesher/models.ts | 167 +-------- renderer/viewer/lib/mesher/world.ts | 4 +- renderer/viewer/lib/worldrendererCommon.ts | 9 + renderer/viewer/three/getPreflatBlock.ts | 30 ++ renderer/viewer/three/instancedRenderer.ts | 375 ++++++++++++-------- renderer/viewer/three/worldrendererThree.ts | 20 +- 7 files changed, 297 insertions(+), 310 deletions(-) create mode 100644 renderer/viewer/three/getPreflatBlock.ts diff --git a/renderer/viewer/lib/mesher/mesher.ts b/renderer/viewer/lib/mesher/mesher.ts index 444093f2..f781275c 100644 --- a/renderer/viewer/lib/mesher/mesher.ts +++ b/renderer/viewer/lib/mesher/mesher.ts @@ -99,7 +99,7 @@ const handleMessage = data => { allDataReady = true workerIndex = data.workerIndex world.instancedBlocks = data.instancedBlocks - world.instancedBlockIds = data.instancedBlockIds || new Map() + world.instancedBlockIds = data.instancedBlockIds || {} break } diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index e2333a03..cb07d01a 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -7,140 +7,6 @@ import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, veca import { INVISIBLE_BLOCKS } from './worldConstants' import { MesherGeometryOutput, HighestBlockInfo, InstancedBlockEntry, InstancingMode } from './shared' -// Hardcoded list of full blocks that can use instancing -export const INSTANCEABLE_BLOCKS = new Set([ - 'grass_block', - 'dirt', - 'stone', - 'cobblestone', - 'mossy_cobblestone', - 'clay', - 'moss_block', - 'spawner', - 'sand', - 'gravel', - 'oak_planks', - 'birch_planks', - 'spruce_planks', - 'jungle_planks', - 'acacia_planks', - 'dark_oak_planks', - 'mangrove_planks', - 'cherry_planks', - 'bamboo_planks', - 'crimson_planks', - 'warped_planks', - 'iron_block', - 'gold_block', - 'diamond_block', - 'emerald_block', - 'netherite_block', - 'coal_block', - 'redstone_block', - 'lapis_block', - 'copper_block', - 'exposed_copper', - 'weathered_copper', - 'oxidized_copper', - 'cut_copper', - 'exposed_cut_copper', - 'weathered_cut_copper', - 'oxidized_cut_copper', - 'waxed_copper_block', - 'waxed_exposed_copper', - 'waxed_weathered_copper', - 'waxed_oxidized_copper', - 'raw_iron_block', - 'raw_copper_block', - 'raw_gold_block', - 'smooth_stone', - 'cobbled_deepslate', - 'deepslate', - 'calcite', - 'tuff', - 'dripstone_block', - 'amethyst_block', - 'budding_amethyst', - 'obsidian', - 'crying_obsidian', - 'bedrock', - 'end_stone', - 'purpur_block', - 'quartz_block', - 'smooth_quartz', - 'nether_bricks', - 'red_nether_bricks', - 'blackstone', - 'gilded_blackstone', - 'polished_blackstone', - 'chiseled_nether_bricks', - 'cracked_nether_bricks', - 'basalt', - 'smooth_basalt', - 'polished_basalt', - 'netherrack', - 'magma_block', - 'soul_sand', - 'soul_soil', - 'ancient_debris', - 'bone_block', - 'packed_ice', - 'blue_ice', - 'ice', - 'snow_block', - 'powder_snow', - 'white_wool', - 'orange_wool', - 'magenta_wool', - 'light_blue_wool', - 'yellow_wool', - 'lime_wool', - 'pink_wool', - 'gray_wool', - 'light_gray_wool', - 'cyan_wool', - 'purple_wool', - 'blue_wool', - 'brown_wool', - 'green_wool', - 'red_wool', - 'black_wool', - 'white_concrete', - 'orange_concrete', - 'magenta_concrete', - 'light_blue_concrete', - 'yellow_concrete', - 'lime_concrete', - 'pink_concrete', - 'gray_concrete', - 'light_gray_concrete', - 'cyan_concrete', - 'purple_concrete', - 'blue_concrete', - 'brown_concrete', - 'green_concrete', - 'red_concrete', - 'black_concrete', - 'white_terracotta', - 'orange_terracotta', - 'magenta_terracotta', - 'light_blue_terracotta', - 'yellow_terracotta', - 'lime_terracotta', - 'pink_terracotta', - 'gray_terracotta', - 'light_gray_terracotta', - 'cyan_terracotta', - 'purple_terracotta', - 'blue_terracotta', - 'brown_terracotta', - 'green_terracotta', - 'red_terracotta', - 'black_terracotta', - 'terracotta', - 'glazed_terracotta', -]) - let blockProvider: WorldBlockProvider const tints: any = {} @@ -720,9 +586,14 @@ const getInstancedBlockTextureInfo = (block: Block) => { return textureInfo } -const isBlockInstanceable = (block: Block): boolean => { - // Only instanceable if it's a full cube block without complex geometry - if (!INSTANCEABLE_BLOCKS.has(block.name)) return false +const isBlockInstanceable = (world: World, block: Block): boolean => { + // Use dynamic instanceable blocks data if available + const instancedBlocks = world?.instancedBlocks + if (Array.isArray(instancedBlocks)) { + if (!instancedBlocks.includes(block.name)) return false + } else { + return false + } // Check if it's actually a full cube (no rotations, no complex models) if (!block.models || block.models.length !== 1) return false @@ -839,7 +710,7 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W } if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) { // Check if this block can use instanced rendering - if (enableInstancedRendering && isBlockInstanceable(block)) { + if (enableInstancedRendering && isBlockInstanceable(world, block)) { // Check if block should be culled (all faces hidden by neighbors) if (shouldCullInstancedBlock(world, cursor, block)) { // Block is completely surrounded, skip rendering @@ -850,18 +721,16 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W if (!attr.instancedBlocks[blockKey]) { const textureInfo = getInstancedBlockTextureInfo(block) // Get or create block ID - let blockId = world.instancedBlockIds.get(block.name) - if (blockId === undefined) { - blockId = world.instancedBlockIds.size - world.instancedBlockIds.set(block.name, blockId) - } + const blockId = world.instancedBlockIds[block.stateId] - attr.instancedBlocks[blockKey] = { - blockId, - blockName: block.name, - stateId: block.stateId, - textureInfo, - positions: [] + if (blockId !== undefined) { + attr.instancedBlocks[blockKey] = { + blockId, + blockName: block.name, + stateId: block.stateId, + textureInfo, + positions: [] + } } } attr.instancedBlocks[blockKey].positions.push({ diff --git a/renderer/viewer/lib/mesher/world.ts b/renderer/viewer/lib/mesher/world.ts index 110fb93c..bcff11b7 100644 --- a/renderer/viewer/lib/mesher/world.ts +++ b/renderer/viewer/lib/mesher/world.ts @@ -43,7 +43,7 @@ export class World { sentBlockStateModels = new Set() blockStateModelInfo = new Map() instancedBlocks: Record = {} - instancedBlockIds = new Map() + instancedBlockIds = {} as Record constructor (version: string) { this.Chunk = Chunks(version) as any @@ -123,7 +123,6 @@ export class World { if (!(pos instanceof Vec3)) pos = new Vec3(...pos as [number, number, number]) const key = columnKey(Math.floor(pos.x / 16) * 16, Math.floor(pos.z / 16) * 16) const blockPosKey = `${pos.x},${pos.y},${pos.z}` - const modelOverride = this.customBlockModels.get(key)?.[blockPosKey] const column = this.columns[key] // null column means chunk not loaded @@ -133,6 +132,7 @@ export class World { const locInChunk = posInChunk(loc) const stateId = column.getBlockStateId(locInChunk) + const modelOverride = stateId ? this.customBlockModels.get(key)?.[blockPosKey] : undefined const cacheKey = getBlockAssetsCacheKey(stateId, modelOverride) if (!this.blockCache[cacheKey]) { diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index daaf6bfd..b176deea 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -160,6 +160,9 @@ export abstract class WorldRendererCommon abstract changeBackgroundColor (color: [number, number, number]): void + // Optional method for getting instanced blocks data (implemented by Three.js renderer) + getInstancedBlocksData? (): { instanceableBlocks: Set, allBlocksStateIdToModelIdMap: Record } | undefined + worldRendererConfig: WorldRendererConfig playerStateReactive: PlayerStateReactive playerStateUtils: PlayerStateUtils @@ -596,6 +599,10 @@ export abstract class WorldRendererCommon const resources = this.resourcesManager.currentResources if (this.workers.length === 0) throw new Error('workers not initialized yet') + + // Get instanceable blocks data if available (Three.js specific) + const instancedBlocksData = this.getInstancedBlocksData?.() + for (const [i, worker] of this.workers.entries()) { const { blockstatesModels } = resources @@ -607,6 +614,8 @@ export abstract class WorldRendererCommon }, blockstatesModels, config: this.getMesherConfig(), + instancedBlocks: instancedBlocksData?.instanceableBlocks ? [...instancedBlocksData.instanceableBlocks] : [], + instancedBlockIds: instancedBlocksData?.allBlocksStateIdToModelIdMap || {} }) } diff --git a/renderer/viewer/three/getPreflatBlock.ts b/renderer/viewer/three/getPreflatBlock.ts new file mode 100644 index 00000000..2c4e36a6 --- /dev/null +++ b/renderer/viewer/three/getPreflatBlock.ts @@ -0,0 +1,30 @@ +import legacyJson from '../../../src/preflatMap.json' + +export const getPreflatBlock = (block, reportIssue?: () => void) => { + const b = block + b._properties = {} + + const namePropsStr = legacyJson.blocks[b.type + ':' + b.metadata] || findClosestLegacyBlockFallback(b.type, b.metadata, reportIssue) + if (namePropsStr) { + b.name = namePropsStr.split('[')[0] + const propsStr = namePropsStr.split('[')?.[1]?.split(']') + if (propsStr) { + const newProperties = Object.fromEntries(propsStr.join('').split(',').map(x => { + let [key, val] = x.split('=') + if (!isNaN(val)) val = parseInt(val, 10) + return [key, val] + })) + b._properties = newProperties + } + } + return b +} + +const findClosestLegacyBlockFallback = (id, metadata, reportIssue) => { + reportIssue?.() + for (const [key, value] of Object.entries(legacyJson.blocks)) { + const [idKey, meta] = key.split(':') + if (idKey === id) return value + } + return null +} diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index 105cd3bb..f1306156 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -1,142 +1,12 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' +import { versionToNumber } from 'flying-squid/dist/utils' +import PrismarineBlock, { Block } from 'prismarine-block' +import { IndexedBlock } from 'minecraft-data' import moreBlockData from '../lib/moreBlockDataGenerated.json' +import { getPreflatBlock } from './getPreflatBlock' import { WorldRendererThree } from './worldrendererThree' -// Hardcoded list of full blocks that can use instancing -export const INSTANCEABLE_BLOCKS = new Set([ - 'grass_block', - 'dirt', - 'stone', - 'cobblestone', - 'mossy_cobblestone', - 'clay', - 'moss_block', - 'spawner', - 'sand', - 'gravel', - 'oak_planks', - 'birch_planks', - 'spruce_planks', - 'jungle_planks', - 'acacia_planks', - 'dark_oak_planks', - 'mangrove_planks', - 'cherry_planks', - 'bamboo_planks', - 'crimson_planks', - 'warped_planks', - 'iron_block', - 'gold_block', - 'diamond_block', - 'emerald_block', - 'netherite_block', - 'coal_block', - 'redstone_block', - 'lapis_block', - 'copper_block', - 'exposed_copper', - 'weathered_copper', - 'oxidized_copper', - 'cut_copper', - 'exposed_cut_copper', - 'weathered_cut_copper', - 'oxidized_cut_copper', - 'waxed_copper_block', - 'waxed_exposed_copper', - 'waxed_weathered_copper', - 'waxed_oxidized_copper', - 'raw_iron_block', - 'raw_copper_block', - 'raw_gold_block', - 'smooth_stone', - 'cobbled_deepslate', - 'deepslate', - 'calcite', - 'tuff', - 'dripstone_block', - 'amethyst_block', - 'budding_amethyst', - 'obsidian', - 'crying_obsidian', - 'bedrock', - 'end_stone', - 'purpur_block', - 'quartz_block', - 'smooth_quartz', - 'nether_bricks', - 'red_nether_bricks', - 'blackstone', - 'gilded_blackstone', - 'polished_blackstone', - 'chiseled_nether_bricks', - 'cracked_nether_bricks', - 'basalt', - 'smooth_basalt', - 'polished_basalt', - 'netherrack', - 'magma_block', - 'soul_sand', - 'soul_soil', - 'ancient_debris', - 'bone_block', - 'packed_ice', - 'blue_ice', - 'ice', - 'snow_block', - 'powder_snow', - 'white_wool', - 'orange_wool', - 'magenta_wool', - 'light_blue_wool', - 'yellow_wool', - 'lime_wool', - 'pink_wool', - 'gray_wool', - 'light_gray_wool', - 'cyan_wool', - 'purple_wool', - 'blue_wool', - 'brown_wool', - 'green_wool', - 'red_wool', - 'black_wool', - 'white_concrete', - 'orange_concrete', - 'magenta_concrete', - 'light_blue_concrete', - 'yellow_concrete', - 'lime_concrete', - 'pink_concrete', - 'gray_concrete', - 'light_gray_concrete', - 'cyan_concrete', - 'purple_concrete', - 'blue_concrete', - 'brown_concrete', - 'green_concrete', - 'red_concrete', - 'black_concrete', - 'white_terracotta', - 'orange_terracotta', - 'magenta_terracotta', - 'light_blue_terracotta', - 'yellow_terracotta', - 'lime_terracotta', - 'pink_terracotta', - 'gray_terracotta', - 'light_gray_terracotta', - 'cyan_terracotta', - 'purple_terracotta', - 'blue_terracotta', - 'brown_terracotta', - 'green_terracotta', - 'red_terracotta', - 'black_terracotta', - 'terracotta', - 'glazed_terracotta', -]) - // Helper function to parse RGB color strings from moreBlockDataGenerated.json function parseRgbColor (rgbString: string): number { const match = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(rgbString) @@ -162,6 +32,21 @@ export interface InstancedSectionData { shouldUseInstancedOnly: boolean } +export interface InstancedBlockModelData { + textures: number[] + rotation: number[] + transparent?: boolean + emitLight?: number + filterLight?: number +} + +export interface InstancedBlocksConfig { + instanceableBlocks: Set + blocksDataModel: Record + allBlocksStateIdToModelIdMap: Record + interestedTextureTiles: Set +} + export class InstancedRenderer { private readonly instancedMeshes = new Map() private readonly blockCounts = new Map() @@ -173,9 +58,191 @@ export class InstancedRenderer { private readonly blockNameToId = new Map() private nextBlockId = 0 + // New properties for dynamic block detection + private instancedBlocksConfig: InstancedBlocksConfig | null = null + constructor (private readonly worldRenderer: WorldRendererThree) { this.cubeGeometry = this.createCubeGeometry() - this.initInstancedMeshes() + } + + prepareInstancedBlocksData (): InstancedBlocksConfig { + const blocksMap = { + 'double_stone_slab': 'stone', + 'stone_slab': 'stone', + 'oak_stairs': 'planks', + 'stone_stairs': 'stone', + 'glass_pane': 'stained_glass', + 'brick_stairs': 'brick_block', + 'stone_brick_stairs': 'stonebrick', + 'nether_brick_stairs': 'nether_brick', + 'double_wooden_slab': 'planks', + 'wooden_slab': 'planks', + 'sandstone_stairs': 'sandstone', + 'cobblestone_wall': 'cobblestone', + 'quartz_stairs': 'quartz_block', + 'stained_glass_pane': 'stained_glass', + 'red_sandstone_stairs': 'red_sandstone', + 'stone_slab2': 'stone_slab', + 'purpur_stairs': 'purpur_block', + 'purpur_slab': 'purpur_block', + } + + const isPreflat = versionToNumber(this.worldRenderer.version) < versionToNumber('1.13') + const PBlockOriginal = PrismarineBlock(this.worldRenderer.version) + + const instanceableBlocks = new Set() + const blocksDataModel = {} as Record + const interestedTextureTiles = new Set() + const blocksProcessed = {} as Record + let i = 0 + const allBlocksStateIdToModelIdMap = {} as Record + + const addBlockModel = (state: number, name: string, props: Record, mcBlockData?: IndexedBlock, defaultState = false) => { + const possibleIssues = [] as string[] + const { currentResources } = this.worldRenderer.resourcesManager + if (!currentResources?.worldBlockProvider) return + + const models = currentResources.worldBlockProvider.getAllResolvedModels0_1({ + name, + properties: props + }, isPreflat, possibleIssues, [], [], true) + + // skipping composite blocks + if (models.length !== 1 || !models[0]![0].elements) { + return + } + const elements = models[0]![0]?.elements + if (!elements || (elements.length !== 1 && name !== 'grass_block')) { + return + } + const elem = elements[0] + if (elem.from[0] !== 0 || elem.from[1] !== 0 || elem.from[2] !== 0 || elem.to[0] !== 16 || elem.to[1] !== 16 || elem.to[2] !== 16) { + // not full block + return + } + + const facesMapping = [ + ['front', 'south'], + ['bottom', 'down'], + ['top', 'up'], + ['right', 'east'], + ['left', 'west'], + ['back', 'north'], + ] + + const blockData: InstancedBlockModelData = { + textures: [0, 0, 0, 0, 0, 0], + rotation: [0, 0, 0, 0, 0, 0] + } + + for (const [face, { texture, cullface, rotation = 0 }] of Object.entries(elem.faces)) { + const faceIndex = facesMapping.findIndex(x => x.includes(face)) + if (faceIndex === -1) { + throw new Error(`Unknown face ${face}`) + } + + blockData.textures[faceIndex] = texture.tileIndex + blockData.rotation[faceIndex] = rotation / 90 + if (Math.floor(blockData.rotation[faceIndex]) !== blockData.rotation[faceIndex]) { + throw new Error(`Invalid rotation ${rotation} ${name}`) + } + interestedTextureTiles.add(texture.debugName) + } + + const k = i++ + allBlocksStateIdToModelIdMap[state] = k + blocksDataModel[k] = blockData + instanceableBlocks.add(name) + blocksProcessed[name] = true + + if (mcBlockData) { + blockData.transparent = mcBlockData.transparent + blockData.emitLight = mcBlockData.emitLight + blockData.filterLight = mcBlockData.filterLight + } + } + + // Add unknown block model + addBlockModel(-1, 'unknown', {}) + + // Handle texture overrides for special blocks + const textureOverrideFullBlocks = { + water: 'water_still', + lava: 'lava_still', + } + + // Process all blocks to find instanceable ones + for (const b of (globalThis as any).loadedData.blocksArray) { + for (let state = b.minStateId; state <= b.maxStateId; state++) { + const mapping = blocksMap[b.name] + const block = PBlockOriginal.fromStateId(mapping && (globalThis as any).loadedData.blocksByName[mapping] ? (globalThis as any).loadedData.blocksByName[mapping].defaultState : state, 0) + if (isPreflat) { + getPreflatBlock(block) + } + + const textureOverride = textureOverrideFullBlocks[block.name] as string | undefined + if (textureOverride) { + const k = i++ + const { currentResources } = this.worldRenderer.resourcesManager + if (!currentResources?.worldBlockProvider) continue + const texture = currentResources.worldBlockProvider.getTextureInfo(textureOverride) + if (!texture) { + console.warn('Missing texture override for', block.name) + continue + } + const texIndex = texture.tileIndex + allBlocksStateIdToModelIdMap[state] = k + const blockData: InstancedBlockModelData = { + textures: [texIndex, texIndex, texIndex, texIndex, texIndex, texIndex], + rotation: [0, 0, 0, 0, 0, 0], + filterLight: b.filterLight + } + blocksDataModel[k] = blockData + instanceableBlocks.add(block.name) + interestedTextureTiles.add(textureOverride) + continue + } + + // Check if block is a full cube + if (block.shapes.length === 0 || !block.shapes.every(shape => { + return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1 + })) { + continue + } + + addBlockModel(state, block.name, block.getProperties(), b, state === b.defaultState) + } + } + + return { + instanceableBlocks, + blocksDataModel, + allBlocksStateIdToModelIdMap, + interestedTextureTiles + } + } + + initializeInstancedMeshes () { + if (!this.instancedBlocksConfig) { + console.warn('Instanced blocks config not prepared') + return + } + + // Create InstancedMesh for each instanceable block type + for (const blockName of this.instancedBlocksConfig.instanceableBlocks) { + const material = this.createBlockMaterial(blockName) + const mesh = new THREE.InstancedMesh( + this.cubeGeometry, + material, + this.maxInstancesPerBlock + ) + mesh.name = `instanced_${blockName}` + mesh.frustumCulled = false // Important for performance + mesh.count = 0 // Start with 0 instances + + this.instancedMeshes.set(this.getBlockId(blockName), mesh) + this.worldRenderer.scene.add(mesh) + } } private createCubeGeometry (): THREE.BoxGeometry { @@ -210,24 +277,6 @@ export class InstancedRenderer { return geometry } - private initInstancedMeshes () { - // Create InstancedMesh for each instanceable block type - for (const blockName of INSTANCEABLE_BLOCKS) { - const material = this.createBlockMaterial(blockName) - const mesh = new THREE.InstancedMesh( - this.cubeGeometry, - material, - this.maxInstancesPerBlock - ) - mesh.name = `instanced_${blockName}` - mesh.frustumCulled = false // Important for performance - mesh.count = 0 // Start with 0 instances - - this.instancedMeshes.set(this.getBlockId(blockName), mesh) - this.worldRenderer.scene.add(mesh) - } - } - private createBlockMaterial (blockName: string, textureInfo?: { u: number, v: number, su: number, sv: number }): THREE.Material { const { enableSingleColorMode } = this.worldRenderer.worldRendererConfig @@ -270,7 +319,7 @@ export class InstancedRenderer { // Add new instances for (const [blockName, blockData] of instancedBlocks) { - if (!INSTANCEABLE_BLOCKS.has(blockName)) continue + if (!this.isBlockInstanceable(blockName)) continue const mesh = this.instancedMeshes.get(this.getBlockId(blockName)) if (!mesh) continue @@ -442,7 +491,7 @@ export class InstancedRenderer { } isBlockInstanceable (blockName: string): boolean { - return INSTANCEABLE_BLOCKS.has(blockName) + return this.instancedBlocksConfig?.instanceableBlocks.has(blockName) ?? false } updateMaterials () { @@ -508,4 +557,20 @@ export class InstancedRenderer { } return blockId } + + // New method to prepare and initialize everything + prepareAndInitialize () { + console.log('Preparing instanced blocks data...') + this.instancedBlocksConfig = this.prepareInstancedBlocksData() + console.log(`Found ${this.instancedBlocksConfig.instanceableBlocks.size} instanceable blocks:`, + [...this.instancedBlocksConfig.instanceableBlocks].slice(0, 10).join(', '), + this.instancedBlocksConfig.instanceableBlocks.size > 10 ? '...' : '') + + this.initializeInstancedMeshes() + } + + // Method to get the current configuration + getInstancedBlocksConfig (): InstancedBlocksConfig | null { + return this.instancedBlocksConfig + } } diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 82080b3f..21f6f68f 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -24,7 +24,7 @@ import { ThreeJsSound } from './threeJsSound' import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' import { Fountain } from './threeJsParticles' -import { InstancedRenderer, INSTANCEABLE_BLOCKS } from './instancedRenderer' +import { InstancedRenderer } from './instancedRenderer' type SectionKey = string @@ -97,8 +97,6 @@ export class WorldRendererThree extends WorldRendererCommon { this.addDebugOverlay() this.resetScene() - void this.init() - this.soundSystem = new ThreeJsSound(this) this.cameraShake = new CameraShake(this, this.onRender) this.media = new ThreeJsMedia(this) @@ -111,6 +109,8 @@ export class WorldRendererThree extends WorldRendererCommon { this.finishChunk(chunkKey) }) this.worldSwitchActions() + + void this.init() } get cameraObject () { @@ -143,6 +143,16 @@ export class WorldRendererThree extends WorldRendererCommon { } } + getInstancedBlocksData () { + const config = this.instancedRenderer.getInstancedBlocksConfig() + if (!config) return undefined + + return { + instanceableBlocks: config.instanceableBlocks, + allBlocksStateIdToModelIdMap: config.allBlocksStateIdToModelIdMap + } + } + updatePlayerEntity (e: any) { this.entities.handlePlayerEntity(e) } @@ -228,8 +238,12 @@ export class WorldRendererThree extends WorldRendererCommon { oldItemsTexture.dispose() } + // Prepare and initialize instanced renderer with dynamic block detection + this.instancedRenderer.prepareAndInitialize() + await super.updateAssetsData() this.onAllTexturesLoaded() + // Update instanced renderer materials when textures change this.instancedRenderer.updateMaterials() if (Object.keys(this.loadedChunks).length > 0) { From 102520233a98f9d327f80060f96b3e6333379664 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 03:14:08 +0300 Subject: [PATCH 06/86] code cleanup towards text --- renderer/viewer/lib/mesher/models.ts | 63 +------- renderer/viewer/lib/mesher/shared.ts | 6 - renderer/viewer/three/instancedRenderer.ts | 169 ++++++++------------ renderer/viewer/three/worldrendererThree.ts | 9 +- 4 files changed, 69 insertions(+), 178 deletions(-) diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index cb07d01a..c2b76bce 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -5,7 +5,8 @@ import { BlockType } from '../../../playground/shared' import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock } from './world' import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon' import { INVISIBLE_BLOCKS } from './worldConstants' -import { MesherGeometryOutput, HighestBlockInfo, InstancedBlockEntry, InstancingMode } from './shared' +import { MesherGeometryOutput, InstancingMode } from './shared' +import { isBlockInstanceable } from './instancingUtils' let blockProvider: WorldBlockProvider @@ -550,64 +551,6 @@ const shouldCullInstancedBlock = (world: World, cursor: Vec3, block: Block): boo return true } -const getInstancedBlockTextureInfo = (block: Block) => { - // Cache texture info by block state ID - const cacheKey = block.stateId - if (textureInfoCache.has(cacheKey)) { - return textureInfoCache.get(cacheKey) - } - - // Get texture info from the first available face - const model = block.models?.[0]?.[0] - if (!model?.elements?.[0]) return undefined - - const element = model.elements[0] - - // Try to find a face with texture - prefer visible faces - const faceOrder = ['up', 'north', 'east', 'south', 'west', 'down'] - let textureInfo: any - - for (const faceName of faceOrder) { - const face = element.faces[faceName] - if (face?.texture) { - const texture = face.texture as any - textureInfo = { - u: texture.u || 0, - v: texture.v || 0, - su: texture.su || 1, - sv: texture.sv || 1 - } - break - } - } - - // Cache the result - textureInfoCache.set(cacheKey, textureInfo) - return textureInfo -} - -const isBlockInstanceable = (world: World, block: Block): boolean => { - // Use dynamic instanceable blocks data if available - const instancedBlocks = world?.instancedBlocks - if (Array.isArray(instancedBlocks)) { - if (!instancedBlocks.includes(block.name)) return false - } else { - return false - } - - // Check if it's actually a full cube (no rotations, no complex models) - if (!block.models || block.models.length !== 1) return false - - const model = block.models[0][0] // First variant of first model - if (!model || model.x || model.y || model.z) return false // No rotations - - // Check if all elements are full cubes - return (model.elements ?? []).every(element => { - return element.from[0] === 0 && element.from[1] === 0 && element.from[2] === 0 && - element.to[0] === 16 && element.to[1] === 16 && element.to[2] === 16 - }) -} - let unknownBlockModel: BlockModelPartsResolved export function getSectionGeometry (sx: number, sy: number, sz: number, world: World, instancingMode = InstancingMode.None): MesherGeometryOutput { let delayedRender = [] as Array<() => void> @@ -719,7 +662,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W const blockKey = block.name if (!attr.instancedBlocks[blockKey]) { - const textureInfo = getInstancedBlockTextureInfo(block) // Get or create block ID const blockId = world.instancedBlockIds[block.stateId] @@ -728,7 +670,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W blockId, blockName: block.name, stateId: block.stateId, - textureInfo, positions: [] } } diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 97f9cf1a..aa6fcd84 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -31,12 +31,6 @@ export type InstancedBlockEntry = { blockId: number // Unique ID for this block type blockName: string stateId: number - textureInfo?: { - u: number - v: number - su: number - sv: number - } positions: Array<{ x: number, y: number, z: number }> } diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index f1306156..ee79201f 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -4,6 +4,7 @@ import { versionToNumber } from 'flying-squid/dist/utils' import PrismarineBlock, { Block } from 'prismarine-block' import { IndexedBlock } from 'minecraft-data' import moreBlockData from '../lib/moreBlockDataGenerated.json' +import { MesherGeometryOutput } from '../lib/mesher/shared' import { getPreflatBlock } from './getPreflatBlock' import { WorldRendererThree } from './worldrendererThree' @@ -45,13 +46,14 @@ export interface InstancedBlocksConfig { blocksDataModel: Record allBlocksStateIdToModelIdMap: Record interestedTextureTiles: Set + textureInfoByBlockId: Record } export class InstancedRenderer { private readonly instancedMeshes = new Map() private readonly blockCounts = new Map() private readonly sectionInstances = new Map>() - private readonly maxInstancesPerBlock = 100_000 + private readonly maxInstancesPerBlock = process.env.NODE_ENV === 'development' ? 100_000 : Infinity private readonly cubeGeometry: THREE.BoxGeometry private readonly tempMatrix = new THREE.Matrix4() private readonly blockIdToName = new Map() @@ -96,6 +98,7 @@ export class InstancedRenderer { const blocksProcessed = {} as Record let i = 0 const allBlocksStateIdToModelIdMap = {} as Record + const textureInfoByBlockId: Record = {} const addBlockModel = (state: number, name: string, props: Record, mcBlockData?: IndexedBlock, defaultState = false) => { const possibleIssues = [] as string[] @@ -135,6 +138,7 @@ export class InstancedRenderer { rotation: [0, 0, 0, 0, 0, 0] } + const blockId = i++ for (const [face, { texture, cullface, rotation = 0 }] of Object.entries(elem.faces)) { const faceIndex = facesMapping.findIndex(x => x.includes(face)) if (faceIndex === -1) { @@ -147,11 +151,11 @@ export class InstancedRenderer { throw new Error(`Invalid rotation ${rotation} ${name}`) } interestedTextureTiles.add(texture.debugName) + textureInfoByBlockId[blockId] = { u: texture.u, v: texture.v, su: texture.su, sv: texture.sv } } - const k = i++ - allBlocksStateIdToModelIdMap[state] = k - blocksDataModel[k] = blockData + allBlocksStateIdToModelIdMap[state] = blockId + blocksDataModel[blockId] = blockData instanceableBlocks.add(name) blocksProcessed[name] = true @@ -182,7 +186,7 @@ export class InstancedRenderer { const textureOverride = textureOverrideFullBlocks[block.name] as string | undefined if (textureOverride) { - const k = i++ + const blockId = i++ const { currentResources } = this.worldRenderer.resourcesManager if (!currentResources?.worldBlockProvider) continue const texture = currentResources.worldBlockProvider.getTextureInfo(textureOverride) @@ -191,15 +195,16 @@ export class InstancedRenderer { continue } const texIndex = texture.tileIndex - allBlocksStateIdToModelIdMap[state] = k + allBlocksStateIdToModelIdMap[state] = blockId const blockData: InstancedBlockModelData = { textures: [texIndex, texIndex, texIndex, texIndex, texIndex, texIndex], rotation: [0, 0, 0, 0, 0, 0], filterLight: b.filterLight } - blocksDataModel[k] = blockData + blocksDataModel[blockId] = blockData instanceableBlocks.add(block.name) interestedTextureTiles.add(textureOverride) + textureInfoByBlockId[blockId] = { u: texture.u, v: texture.v, su: texture.su, sv: texture.sv } continue } @@ -218,7 +223,20 @@ export class InstancedRenderer { instanceableBlocks, blocksDataModel, allBlocksStateIdToModelIdMap, - interestedTextureTiles + interestedTextureTiles, + textureInfoByBlockId + } + } + + private createBlockMaterial (blockName: string, textureInfo?: { u: number, v: number, su: number, sv: number }): THREE.Material { + const { enableSingleColorMode } = this.worldRenderer.worldRendererConfig + + if (enableSingleColorMode) { + // Ultra-performance mode: solid colors only + const color = this.getBlockColor(blockName) + return new THREE.MeshBasicMaterial({ color }) + } else { + return this.worldRenderer.material } } @@ -230,18 +248,31 @@ export class InstancedRenderer { // Create InstancedMesh for each instanceable block type for (const blockName of this.instancedBlocksConfig.instanceableBlocks) { - const material = this.createBlockMaterial(blockName) + const blockId = this.getBlockId(blockName) + if (this.instancedMeshes.has(blockId)) continue // Skip if already exists + + const textureInfo = this.instancedBlocksConfig.textureInfoByBlockId[blockId] + + const geometry = textureInfo ? this.createCustomGeometry(textureInfo) : this.cubeGeometry + const material = this.createBlockMaterial(blockName, textureInfo) + const mesh = new THREE.InstancedMesh( - this.cubeGeometry, + geometry, material, this.maxInstancesPerBlock ) mesh.name = `instanced_${blockName}` mesh.frustumCulled = false // Important for performance - mesh.count = 0 // Start with 0 instances + mesh.count = 0 - this.instancedMeshes.set(this.getBlockId(blockName), mesh) + this.instancedMeshes.set(blockId, mesh) this.worldRenderer.scene.add(mesh) + + if (textureInfo) { + console.log(`Created instanced mesh for ${blockName} with texture info:`, textureInfo) + } else { + console.warn(`No texture info found for block ${blockName}`) + } } } @@ -255,51 +286,33 @@ export class InstancedRenderer { private createCustomGeometry (textureInfo: { u: number, v: number, su: number, sv: number }): THREE.BufferGeometry { // Create custom geometry with specific UV coordinates for this block type - // This would be more complex but would give perfect texture mapping const geometry = new THREE.BoxGeometry(1, 1, 1) // Get UV attribute const uvAttribute = geometry.getAttribute('uv') as THREE.BufferAttribute const uvs = uvAttribute.array as Float32Array - // Modify UV coordinates to use the specific texture region - // This is a simplified version - real implementation would need to handle all 6 faces properly - for (let i = 0; i < uvs.length; i += 2) { - const u = uvs[i] - const v = uvs[i + 1] + // BoxGeometry has 6 faces, each with 2 triangles (4 vertices), so 24 UV pairs total + // The order in Three.js BoxGeometry is: +X, -X, +Y, -Y, +Z, -Z + // We need to map the texture coordinates properly for each face - // Map from 0-1 to the specific texture region - uvs[i] = textureInfo.u + u * textureInfo.su - uvs[i + 1] = textureInfo.v + v * textureInfo.sv + if (this.instancedBlocksConfig && textureInfo) { + // For now, apply the same texture to all faces + // In the future, this could be enhanced to use different textures per face + for (let i = 0; i < uvs.length; i += 2) { + const u = uvs[i] + const v = uvs[i + 1] + + // Map from 0-1 to the specific texture region in the atlas + uvs[i] = textureInfo.u + u * textureInfo.su + uvs[i + 1] = textureInfo.v + v * textureInfo.sv + } } uvAttribute.needsUpdate = true return geometry } - private createBlockMaterial (blockName: string, textureInfo?: { u: number, v: number, su: number, sv: number }): THREE.Material { - const { enableSingleColorMode } = this.worldRenderer.worldRendererConfig - - if (enableSingleColorMode) { - // Ultra-performance mode: solid colors only - const color = this.getBlockColor(blockName) - return new THREE.MeshBasicMaterial({ color }) - } else { - // Use texture from the blocks atlas - // eslint-disable-next-line no-lonely-if - if (this.worldRenderer.material.map) { - const material = this.worldRenderer.material.clone() - // The texture is already the correct blocks atlas texture - // Individual block textures are handled by UV coordinates in the geometry - return material - } else { - // Fallback to colored material - const color = this.getBlockColor(blockName) - return new THREE.MeshLambertMaterial({ color }) - } - } - } - private getBlockColor (blockName: string): number { // Get color from moreBlockDataGenerated.json const rgbString = moreBlockData.colors[blockName] @@ -311,43 +324,7 @@ export class InstancedRenderer { return 0x99_99_99 } - handleInstancedGeometry (data: InstancedSectionData) { - const { sectionKey, instancedBlocks } = data - - // Clear previous instances for this section - this.clearSectionInstances(sectionKey) - - // Add new instances - for (const [blockName, blockData] of instancedBlocks) { - if (!this.isBlockInstanceable(blockName)) continue - - const mesh = this.instancedMeshes.get(this.getBlockId(blockName)) - if (!mesh) continue - - const currentCount = this.blockCounts.get(this.getBlockId(blockName)) || 0 - let instanceIndex = currentCount - - for (const pos of blockData.positions) { - if (instanceIndex >= this.maxInstancesPerBlock) { - console.warn(`Exceeded max instances for block ${blockName} (${instanceIndex}/${this.maxInstancesPerBlock})`) - break - } - - this.tempMatrix.setPosition(pos.x, pos.y, pos.z) - mesh.setMatrixAt(instanceIndex, this.tempMatrix) - instanceIndex++ - } - - mesh.count = instanceIndex - mesh.instanceMatrix.needsUpdate = true - this.blockCounts.set(this.getBlockId(blockName), instanceIndex) - } - } - - handleInstancedBlocksFromWorker (instancedBlocks: Record, sectionKey: string) { - // Clear existing instances for this section first - this.removeSectionInstances(sectionKey) - + handleInstancedBlocksFromWorker (instancedBlocks: MesherGeometryOutput['instancedBlocks'], sectionKey: string) { // Initialize section tracking if not exists if (!this.sectionInstances.has(sectionKey)) { this.sectionInstances.set(sectionKey, new Map()) @@ -357,23 +334,13 @@ export class InstancedRenderer { for (const [blockName, blockData] of Object.entries(instancedBlocks)) { if (!this.isBlockInstanceable(blockName)) continue - const { blockId } = blockData + const { blockId, stateId } = blockData this.blockIdToName.set(blockId, blockName) - let mesh = this.instancedMeshes.get(blockId) + const mesh = this.instancedMeshes.get(blockId) if (!mesh) { - // Create new mesh if it doesn't exist - const material = this.createBlockMaterial(blockName, blockData.textureInfo) - mesh = new THREE.InstancedMesh( - this.cubeGeometry, - material, - this.maxInstancesPerBlock - ) - mesh.name = `instanced_${blockName}` - mesh.frustumCulled = false - mesh.count = 0 - this.instancedMeshes.set(blockId, mesh) - this.worldRenderer.scene.add(mesh) + console.warn(`Failed to find mesh for block ${blockName}`) + continue } const instanceIndices: number[] = [] @@ -387,7 +354,7 @@ export class InstancedRenderer { } const instanceIndex = currentCount + instanceIndices.length - this.tempMatrix.setPosition(pos.x, pos.y, pos.z) + this.tempMatrix.setPosition(pos.x + 0.5, pos.y + 0.5, pos.z + 0.5) mesh.setMatrixAt(instanceIndex, this.tempMatrix) instanceIndices.push(instanceIndex) } @@ -402,16 +369,6 @@ export class InstancedRenderer { } } - private clearSectionInstances (sectionKey: string) { - // For now, we'll rebuild all instances each time - // This could be optimized to track instances per section - for (const [blockId, mesh] of this.instancedMeshes) { - mesh.count = 0 - mesh.instanceMatrix.needsUpdate = true - this.blockCounts.set(blockId, 0) - } - } - removeSectionInstances (sectionKey: string) { const sectionMap = this.sectionInstances.get(sectionKey) if (!sectionMap) return // Section not tracked diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 21f6f68f..56eaa3d2 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -366,19 +366,18 @@ export class WorldRendererThree extends WorldRendererCommon { } // debugRecomputedDeletedObjects = 0 - handleInstancedBlocks (instancedBlocks: Record, sectionKey: string): void { - this.instancedRenderer.handleInstancedBlocksFromWorker(instancedBlocks, sectionKey) - } - handleWorkerMessage (data: { geometry: MesherGeometryOutput, key, type }): void { if (data.type !== 'geometry') return const chunkCoords = data.key.split(',') const chunkKey = chunkCoords[0] + ',' + chunkCoords[2] + // Always clear old instanced blocks for this section first, then add new ones if any + this.instancedRenderer.removeSectionInstances(data.key) + // Handle instanced blocks data from worker if (data.geometry.instancedBlocks && Object.keys(data.geometry.instancedBlocks).length > 0) { - this.handleInstancedBlocks(data.geometry.instancedBlocks, data.key) + this.instancedRenderer.handleInstancedBlocksFromWorker(data.geometry.instancedBlocks, data.key) } let object: THREE.Object3D = this.sectionObjects[data.key] From 60834169434338f18d5a7d18559a113658f71961 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 03:15:36 +0300 Subject: [PATCH 07/86] utils --- renderer/viewer/lib/mesher/instancingUtils.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 renderer/viewer/lib/mesher/instancingUtils.ts diff --git a/renderer/viewer/lib/mesher/instancingUtils.ts b/renderer/viewer/lib/mesher/instancingUtils.ts new file mode 100644 index 00000000..56947bee --- /dev/null +++ b/renderer/viewer/lib/mesher/instancingUtils.ts @@ -0,0 +1,24 @@ +import { WorldBlock as Block, World } from './world' + +// Returns true if the block is instanceable (full cube, no rotations, etc.) +export const isBlockInstanceable = (world: World, block: Block): boolean => { + // Use dynamic instanceable blocks data if available + const instancedBlocks = world?.instancedBlocks + if (Array.isArray(instancedBlocks)) { + if (!instancedBlocks.includes(block.name)) return false + } else { + return false + } + + // Check if it's actually a full cube (no rotations, no complex models) + if (!block.models || block.models.length !== 1) return false + + const model = block.models[0][0] // First variant of first model + if (!model || model.x || model.y || model.z) return false // No rotations + + // Check if all elements are full cubes + return (model.elements ?? []).every(element => { + return element.from[0] === 0 && element.from[1] === 0 && element.from[2] === 0 && + element.to[0] === 16 && element.to[1] === 16 && element.to[2] === 16 + }) +} From dbce9e7becff0e6af345e3b7916399f4a3432d1d Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 04:17:25 +0300 Subject: [PATCH 08/86] working texturing --- renderer/viewer/lib/worldrendererCommon.ts | 5 + renderer/viewer/three/instancedRenderer.ts | 105 ++++++++++++-------- renderer/viewer/three/worldrendererThree.ts | 2 - 3 files changed, 69 insertions(+), 43 deletions(-) diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index b176deea..85d677fd 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -684,6 +684,11 @@ export abstract class WorldRendererCommon this.checkAllFinished() } + debugRemoveCurrentChunk () { + const [x, z] = chunkPos(this.viewerChunkPosition!) + this.removeColumn(x, z) + } + removeColumn (x, z) { delete this.loadedChunks[`${x},${z}`] for (const worker of this.workers) { diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index ee79201f..4f622114 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -53,7 +53,7 @@ export class InstancedRenderer { private readonly instancedMeshes = new Map() private readonly blockCounts = new Map() private readonly sectionInstances = new Map>() - private readonly maxInstancesPerBlock = process.env.NODE_ENV === 'development' ? 100_000 : Infinity + private readonly maxInstancesPerBlock = process.env.NODE_ENV === 'development' ? 1_000_000 : 10_000_000 private readonly cubeGeometry: THREE.BoxGeometry private readonly tempMatrix = new THREE.Matrix4() private readonly blockIdToName = new Map() @@ -62,12 +62,23 @@ export class InstancedRenderer { // New properties for dynamic block detection private instancedBlocksConfig: InstancedBlocksConfig | null = null + private sharedMaterial: THREE.MeshLambertMaterial | null = null constructor (private readonly worldRenderer: WorldRendererThree) { this.cubeGeometry = this.createCubeGeometry() } prepareInstancedBlocksData (): InstancedBlocksConfig { + if (this.sharedMaterial) { + this.sharedMaterial.dispose() + this.sharedMaterial = null + } + this.sharedMaterial = new THREE.MeshLambertMaterial({ + transparent: true, + alphaTest: 0.1 + }) + this.sharedMaterial.map = this.worldRenderer.material.map + const blocksMap = { 'double_stone_slab': 'stone', 'stone_slab': 'stone', @@ -234,9 +245,11 @@ export class InstancedRenderer { if (enableSingleColorMode) { // Ultra-performance mode: solid colors only const color = this.getBlockColor(blockName) - return new THREE.MeshBasicMaterial({ color }) + const material = new THREE.MeshBasicMaterial({ color }) + material.name = `instanced_color_${blockName}` + return material } else { - return this.worldRenderer.material + return this.sharedMaterial! } } @@ -269,7 +282,7 @@ export class InstancedRenderer { this.worldRenderer.scene.add(mesh) if (textureInfo) { - console.log(`Created instanced mesh for ${blockName} with texture info:`, textureInfo) + // console.log(`Created instanced mesh for ${blockName} with texture info:`, textureInfo) } else { console.warn(`No texture info found for block ${blockName}`) } @@ -331,6 +344,17 @@ export class InstancedRenderer { } const sectionMap = this.sectionInstances.get(sectionKey)! + // Remove old instances for blocks that are being updated + const previousBlockIds = [...sectionMap.keys()] + for (const blockId of previousBlockIds) { + const instanceIndices = sectionMap.get(blockId) + if (instanceIndices) { + this.removeInstancesFromBlock(blockId, instanceIndices) + sectionMap.delete(blockId) + } + } + + // Keep track of blocks that were updated this frame for (const [blockName, blockData] of Object.entries(instancedBlocks)) { if (!this.isBlockInstanceable(blockName)) continue @@ -375,31 +399,29 @@ export class InstancedRenderer { // Remove instances for each block type in this section for (const [blockId, instanceIndices] of sectionMap) { - const mesh = this.instancedMeshes.get(blockId) - if (!mesh) continue - - // For efficiency, we'll rebuild the entire instance array by compacting it - // This removes gaps left by deleted instances - this.compactInstancesForBlock(blockId, instanceIndices) + this.removeInstancesFromBlock(blockId, instanceIndices) } // Remove section from tracking this.sectionInstances.delete(sectionKey) } - private compactInstancesForBlock (blockId: number, indicesToRemove: number[]) { + private removeInstancesFromBlock (blockId: number, indicesToRemove: number[]) { const mesh = this.instancedMeshes.get(blockId) - if (!mesh) return + if (!mesh || indicesToRemove.length === 0) return const currentCount = this.blockCounts.get(blockId) || 0 const removeSet = new Set(indicesToRemove) + // Create mapping from old indices to new indices + const indexMapping = new Map() let writeIndex = 0 const tempMatrix = new THREE.Matrix4() // Compact the instance matrix by removing gaps for (let readIndex = 0; readIndex < currentCount; readIndex++) { if (!removeSet.has(readIndex)) { + indexMapping.set(readIndex, writeIndex) if (writeIndex !== readIndex) { mesh.getMatrixAt(readIndex, tempMatrix) mesh.setMatrixAt(writeIndex, tempMatrix) @@ -408,23 +430,22 @@ export class InstancedRenderer { } } - // Update count and indices in section tracking + // Update count const newCount = writeIndex this.blockCounts.set(blockId, newCount) mesh.count = newCount mesh.instanceMatrix.needsUpdate = true - // Update all section tracking to reflect compacted indices - const offset = 0 + // Update all section tracking to reflect new indices for (const [sectionKey, sectionMap] of this.sectionInstances) { const sectionIndices = sectionMap.get(blockId) if (sectionIndices) { - const compactedIndices = sectionIndices - .filter(index => !removeSet.has(index)) - .map(index => index - removeSet.size + offset) + const updatedIndices = sectionIndices + .map(index => indexMapping.get(index)) + .filter(index => index !== undefined) - if (compactedIndices.length > 0) { - sectionMap.set(blockId, compactedIndices) + if (updatedIndices.length > 0) { + sectionMap.set(blockId, updatedIndices) } else { sectionMap.delete(blockId) } @@ -432,37 +453,38 @@ export class InstancedRenderer { } } - shouldUseInstancedRendering (chunkKey: string): boolean { - const { useInstancedRendering, forceInstancedOnly, instancedOnlyDistance } = this.worldRenderer.worldRendererConfig + // private compactInstancesForBlock (blockId: number, indicesToRemove: number[]) { + // // This method is now replaced by removeInstancesFromBlock + // this.removeInstancesFromBlock(blockId, indicesToRemove) + // } - if (!useInstancedRendering) return false - if (forceInstancedOnly) return true + // shouldUseInstancedRendering (chunkKey: string): boolean { + // const { useInstancedRendering, forceInstancedOnly, instancedOnlyDistance } = this.worldRenderer.worldRendererConfig - // Check distance for automatic switching - const [x, z] = chunkKey.split(',').map(Number) - const chunkPos = new Vec3(x * 16, 0, z * 16) - const [dx, dz] = this.worldRenderer.getDistance(chunkPos) - const maxDistance = Math.max(dx, dz) + // if (!useInstancedRendering) return false + // if (forceInstancedOnly) return true - return maxDistance >= instancedOnlyDistance - } + // // Check distance for automatic switching + // const [x, z] = chunkKey.split(',').map(Number) + // const chunkPos = new Vec3(x * 16, 0, z * 16) + // const [dx, dz] = this.worldRenderer.getDistance(chunkPos) + // const maxDistance = Math.max(dx, dz) + + // return maxDistance >= instancedOnlyDistance + // } isBlockInstanceable (blockName: string): boolean { return this.instancedBlocksConfig?.instanceableBlocks.has(blockName) ?? false } - updateMaterials () { - // Update materials when texture atlas changes + disposeOldMeshes () { for (const [blockId, mesh] of this.instancedMeshes) { - const blockName = this.blockIdToName.get(blockId) - if (blockName) { - const newMaterial = this.createBlockMaterial(blockName) - const oldMaterial = mesh.material - mesh.material = newMaterial - if (oldMaterial instanceof THREE.Material) { - oldMaterial.dispose() - } + if (mesh.material instanceof THREE.Material && mesh.material.name.startsWith('instanced_color_')) { + mesh.material.dispose() } + mesh.geometry.dispose() + this.instancedMeshes.delete(blockId) + this.worldRenderer.scene.remove(mesh) } } @@ -523,6 +545,7 @@ export class InstancedRenderer { [...this.instancedBlocksConfig.instanceableBlocks].slice(0, 10).join(', '), this.instancedBlocksConfig.instanceableBlocks.size > 10 ? '...' : '') + this.disposeOldMeshes() this.initializeInstancedMeshes() } diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 56eaa3d2..b0f880ad 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -244,8 +244,6 @@ export class WorldRendererThree extends WorldRendererCommon { await super.updateAssetsData() this.onAllTexturesLoaded() - // Update instanced renderer materials when textures change - this.instancedRenderer.updateMaterials() if (Object.keys(this.loadedChunks).length > 0) { console.log('rerendering chunks because of texture update') this.rerenderAllChunks() From 9f29491b5dac889d235541eded6a137fa22e4d45 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 04:55:43 +0300 Subject: [PATCH 09/86] maybe support rotation --- renderer/viewer/three/instancedRenderer.ts | 211 +++++++++++++++------ 1 file changed, 155 insertions(+), 56 deletions(-) diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index 4f622114..9d45f21f 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -39,6 +39,7 @@ export interface InstancedBlockModelData { transparent?: boolean emitLight?: number filterLight?: number + textureInfos?: Array<{ u: number, v: number, su: number, sv: number }> // Store texture info for each face } export interface InstancedBlocksConfig { @@ -46,20 +47,23 @@ export interface InstancedBlocksConfig { blocksDataModel: Record allBlocksStateIdToModelIdMap: Record interestedTextureTiles: Set - textureInfoByBlockId: Record } export class InstancedRenderer { private readonly instancedMeshes = new Map() private readonly blockCounts = new Map() private readonly sectionInstances = new Map>() - private readonly maxInstancesPerBlock = process.env.NODE_ENV === 'development' ? 1_000_000 : 10_000_000 private readonly cubeGeometry: THREE.BoxGeometry private readonly tempMatrix = new THREE.Matrix4() private readonly blockIdToName = new Map() private readonly blockNameToId = new Map() private nextBlockId = 0 + // Dynamic instance management + private readonly baseInstancesPerBlock = 100_000 // Base instances per block type + private readonly maxTotalInstances = 10_000_000 // Total instance budget across all blocks + private currentTotalInstances = 0 + // New properties for dynamic block detection private instancedBlocksConfig: InstancedBlocksConfig | null = null private sharedMaterial: THREE.MeshLambertMaterial | null = null @@ -68,6 +72,37 @@ export class InstancedRenderer { this.cubeGeometry = this.createCubeGeometry() } + private getMaxInstancesPerBlock (): number { + const renderDistance = this.worldRenderer.viewDistance + if (renderDistance <= 0) return this.baseInstancesPerBlock + + // Calculate dynamic limit based on render distance + // More render distance = more chunks = need more instances + const distanceFactor = Math.max(1, renderDistance / 8) // Normalize around render distance 8 + const dynamicLimit = Math.floor(this.baseInstancesPerBlock * distanceFactor) + + // Cap at reasonable limits to prevent memory issues + return Math.min(dynamicLimit, 500_000) + } + + private canAddMoreInstances (blockId: number, count: number): boolean { + const currentForBlock = this.blockCounts.get(blockId) || 0 + const maxPerBlock = this.getMaxInstancesPerBlock() + + // Check per-block limit + if (currentForBlock + count > maxPerBlock) { + return false + } + + // Check total instance budget + if (this.currentTotalInstances + count > this.maxTotalInstances) { + console.warn(`Total instance limit reached (${this.currentTotalInstances}/${this.maxTotalInstances}). Consider reducing render distance.`) + return false + } + + return true + } + prepareInstancedBlocksData (): InstancedBlocksConfig { if (this.sharedMaterial) { this.sharedMaterial.dispose() @@ -109,7 +144,6 @@ export class InstancedRenderer { const blocksProcessed = {} as Record let i = 0 const allBlocksStateIdToModelIdMap = {} as Record - const textureInfoByBlockId: Record = {} const addBlockModel = (state: number, name: string, props: Record, mcBlockData?: IndexedBlock, defaultState = false) => { const possibleIssues = [] as string[] @@ -146,7 +180,8 @@ export class InstancedRenderer { const blockData: InstancedBlockModelData = { textures: [0, 0, 0, 0, 0, 0], - rotation: [0, 0, 0, 0, 0, 0] + rotation: [0, 0, 0, 0, 0, 0], + textureInfos: Array.from({ length: 6 }).fill(null).map(() => ({ u: 0, v: 0, su: 0, sv: 0 })) } const blockId = i++ @@ -162,7 +197,14 @@ export class InstancedRenderer { throw new Error(`Invalid rotation ${rotation} ${name}`) } interestedTextureTiles.add(texture.debugName) - textureInfoByBlockId[blockId] = { u: texture.u, v: texture.v, su: texture.su, sv: texture.sv } + + // Store texture info for this face + blockData.textureInfos![faceIndex] = { + u: texture.u, + v: texture.v, + su: texture.su, + sv: texture.sv + } } allBlocksStateIdToModelIdMap[state] = blockId @@ -210,12 +252,17 @@ export class InstancedRenderer { const blockData: InstancedBlockModelData = { textures: [texIndex, texIndex, texIndex, texIndex, texIndex, texIndex], rotation: [0, 0, 0, 0, 0, 0], - filterLight: b.filterLight + filterLight: b.filterLight, + textureInfos: Array.from({ length: 6 }).fill(null).map(() => ({ + u: texture.u, + v: texture.v, + su: texture.su, + sv: texture.sv + })) } blocksDataModel[blockId] = blockData instanceableBlocks.add(block.name) interestedTextureTiles.add(textureOverride) - textureInfoByBlockId[blockId] = { u: texture.u, v: texture.v, su: texture.su, sv: texture.sv } continue } @@ -234,12 +281,11 @@ export class InstancedRenderer { instanceableBlocks, blocksDataModel, allBlocksStateIdToModelIdMap, - interestedTextureTiles, - textureInfoByBlockId + interestedTextureTiles } } - private createBlockMaterial (blockName: string, textureInfo?: { u: number, v: number, su: number, sv: number }): THREE.Material { + private createBlockMaterial (blockName: string): THREE.Material { const { enableSingleColorMode } = this.worldRenderer.worldRendererConfig if (enableSingleColorMode) { @@ -264,15 +310,15 @@ export class InstancedRenderer { const blockId = this.getBlockId(blockName) if (this.instancedMeshes.has(blockId)) continue // Skip if already exists - const textureInfo = this.instancedBlocksConfig.textureInfoByBlockId[blockId] + const blockModelData = this.instancedBlocksConfig.blocksDataModel[blockId] - const geometry = textureInfo ? this.createCustomGeometry(textureInfo) : this.cubeGeometry - const material = this.createBlockMaterial(blockName, textureInfo) + const geometry = blockModelData ? this.createCustomGeometry(0, blockModelData) : this.cubeGeometry + const material = this.createBlockMaterial(blockName) const mesh = new THREE.InstancedMesh( geometry, material, - this.maxInstancesPerBlock + this.getMaxInstancesPerBlock() ) mesh.name = `instanced_${blockName}` mesh.frustumCulled = false // Important for performance @@ -281,10 +327,8 @@ export class InstancedRenderer { this.instancedMeshes.set(blockId, mesh) this.worldRenderer.scene.add(mesh) - if (textureInfo) { - // console.log(`Created instanced mesh for ${blockName} with texture info:`, textureInfo) - } else { - console.warn(`No texture info found for block ${blockName}`) + if (!blockModelData) { + console.warn(`No block model data found for block ${blockName}`) } } } @@ -297,28 +341,86 @@ export class InstancedRenderer { return geometry } - private createCustomGeometry (textureInfo: { u: number, v: number, su: number, sv: number }): THREE.BufferGeometry { - // Create custom geometry with specific UV coordinates for this block type + private createCustomGeometry (stateId: number, blockModelData: InstancedBlockModelData): THREE.BufferGeometry { + // Create custom geometry with specific UV coordinates per face const geometry = new THREE.BoxGeometry(1, 1, 1) // Get UV attribute const uvAttribute = geometry.getAttribute('uv') as THREE.BufferAttribute const uvs = uvAttribute.array as Float32Array - // BoxGeometry has 6 faces, each with 2 triangles (4 vertices), so 24 UV pairs total - // The order in Three.js BoxGeometry is: +X, -X, +Y, -Y, +Z, -Z - // We need to map the texture coordinates properly for each face + if (!blockModelData.textureInfos) { + console.warn('No texture infos available for block model') + return geometry + } - if (this.instancedBlocksConfig && textureInfo) { - // For now, apply the same texture to all faces - // In the future, this could be enhanced to use different textures per face - for (let i = 0; i < uvs.length; i += 2) { - const u = uvs[i] - const v = uvs[i + 1] + // BoxGeometry has 6 faces, each with 4 vertices (8 UV values) + // Three.js BoxGeometry face order: +X, -X, +Y, -Y, +Z, -Z + // Our face mapping: [front, bottom, top, right, left, back] = [south, down, up, east, west, north] + // Map to Three.js indices: [+Z, -Y, +Y, +X, -X, -Z] = [4, 3, 2, 0, 1, 5] - // Map from 0-1 to the specific texture region in the atlas - uvs[i] = textureInfo.u + u * textureInfo.su - uvs[i + 1] = textureInfo.v + v * textureInfo.sv + interface UVVertex { + u: number + v: number + } + + for (let faceIndex = 0; faceIndex < 6; faceIndex++) { + // Map Three.js face index to our face index + let ourFaceIndex: number + switch (faceIndex) { + case 0: ourFaceIndex = 3; break // +X -> right (east) + case 1: ourFaceIndex = 4; break // -X -> left (west) + case 2: ourFaceIndex = 2; break // +Y -> top (up) + case 3: ourFaceIndex = 1; break // -Y -> bottom (down) + case 4: ourFaceIndex = 0; break // +Z -> front (south) + case 5: ourFaceIndex = 5; break // -Z -> back (north) + default: continue + } + + const textureInfo = blockModelData.textureInfos[ourFaceIndex] + const rotation = blockModelData.rotation[ourFaceIndex] + + if (!textureInfo) { + console.warn(`No texture info found for face ${ourFaceIndex}`) + continue + } + + const { u, v, su, sv } = textureInfo + const faceUvStart = faceIndex * 8 + + // Get original UVs for this face + const faceUVs = uvs.slice(faceUvStart, faceUvStart + 8) + + // Apply rotation if needed (0=0°, 1=90°, 2=180°, 3=270°) + if (rotation > 0) { + // Each vertex has 2 UV coordinates (u,v) + // We need to rotate the 4 vertices as a group + const vertices: UVVertex[] = [] + for (let i = 0; i < 8; i += 2) { + vertices.push({ + u: faceUVs[i], + v: faceUVs[i + 1] + }) + } + + // Rotate vertices + const rotatedVertices: UVVertex[] = [] + for (let i = 0; i < 4; i++) { + const srcIndex = (i + rotation) % 4 + rotatedVertices.push(vertices[srcIndex]) + } + + // Write back rotated coordinates + for (let i = 0; i < 4; i++) { + faceUVs[i * 2] = rotatedVertices[i].u + faceUVs[i * 2 + 1] = rotatedVertices[i].v + } + } + + // Apply texture atlas coordinates to the potentially rotated UVs + for (let i = 0; i < 8; i += 2) { + uvs[faceUvStart + i] = u + faceUVs[i] * su + uvs[faceUvStart + i + 1] = v + faceUVs[i + 1] * sv } } @@ -370,10 +472,10 @@ export class InstancedRenderer { const instanceIndices: number[] = [] const currentCount = this.blockCounts.get(blockId) || 0 - // Add new instances for this section + // Add new instances for this section (with limit checking) for (const pos of blockData.positions) { - if (currentCount + instanceIndices.length >= this.maxInstancesPerBlock) { - console.warn(`Exceeded max instances for block ${blockName} (${currentCount + instanceIndices.length}/${this.maxInstancesPerBlock})`) + if (!this.canAddMoreInstances(blockId, 1)) { + console.warn(`Exceeded max instances for block ${blockName} (${currentCount + instanceIndices.length}/${this.getMaxInstancesPerBlock()})`) break } @@ -387,6 +489,7 @@ export class InstancedRenderer { if (instanceIndices.length > 0) { sectionMap.set(blockId, instanceIndices) this.blockCounts.set(blockId, currentCount + instanceIndices.length) + this.currentTotalInstances += instanceIndices.length mesh.count = this.blockCounts.get(blockId) || 0 mesh.instanceMatrix.needsUpdate = true } @@ -413,6 +516,9 @@ export class InstancedRenderer { const currentCount = this.blockCounts.get(blockId) || 0 const removeSet = new Set(indicesToRemove) + // Update total instance count + this.currentTotalInstances -= indicesToRemove.length + // Create mapping from old indices to new indices const indexMapping = new Map() let writeIndex = 0 @@ -453,31 +559,14 @@ export class InstancedRenderer { } } - // private compactInstancesForBlock (blockId: number, indicesToRemove: number[]) { - // // This method is now replaced by removeInstancesFromBlock - // this.removeInstancesFromBlock(blockId, indicesToRemove) - // } - - // shouldUseInstancedRendering (chunkKey: string): boolean { - // const { useInstancedRendering, forceInstancedOnly, instancedOnlyDistance } = this.worldRenderer.worldRendererConfig - - // if (!useInstancedRendering) return false - // if (forceInstancedOnly) return true - - // // Check distance for automatic switching - // const [x, z] = chunkKey.split(',').map(Number) - // const chunkPos = new Vec3(x * 16, 0, z * 16) - // const [dx, dz] = this.worldRenderer.getDistance(chunkPos) - // const maxDistance = Math.max(dx, dz) - - // return maxDistance >= instancedOnlyDistance - // } - isBlockInstanceable (blockName: string): boolean { return this.instancedBlocksConfig?.instanceableBlocks.has(blockName) ?? false } disposeOldMeshes () { + // Reset total instance count since we're clearing everything + this.currentTotalInstances = 0 + for (const [blockId, mesh] of this.instancedMeshes) { if (mesh.material instanceof THREE.Material && mesh.material.name.startsWith('instanced_color_')) { mesh.material.dispose() @@ -486,6 +575,9 @@ export class InstancedRenderer { this.instancedMeshes.delete(blockId) this.worldRenderer.scene.remove(mesh) } + + // Clear counts + this.blockCounts.clear() } destroy () { @@ -517,11 +609,18 @@ export class InstancedRenderer { } } + const maxPerBlock = this.getMaxInstancesPerBlock() + const renderDistance = this.worldRenderer.viewDistance + return { totalInstances, activeBlockTypes, drawCalls: activeBlockTypes, // One draw call per active block type - memoryEstimate: totalInstances * 64 // Rough estimate in bytes + memoryEstimate: totalInstances * 64, // Rough estimate in bytes + maxInstancesPerBlock: maxPerBlock, + totalInstanceBudget: this.maxTotalInstances, + renderDistance, + instanceUtilization: totalInstances / this.maxTotalInstances } } From 7b06561fc7359af28d9a1409225fed07dee10034 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 06:21:13 +0300 Subject: [PATCH 10/86] debug, try to fix growth, fix perf --- renderer/viewer/lib/mesher/instancingUtils.ts | 18 +- renderer/viewer/lib/mesher/models.ts | 46 +-- renderer/viewer/lib/mesher/shared.ts | 3 +- renderer/viewer/three/entities.ts | 6 +- renderer/viewer/three/holdingBlock.ts | 8 +- renderer/viewer/three/instancedRenderer.ts | 308 ++++++++++++++---- renderer/viewer/three/threeJsMedia.ts | 2 +- renderer/viewer/three/worldrendererThree.ts | 27 +- 8 files changed, 308 insertions(+), 110 deletions(-) diff --git a/renderer/viewer/lib/mesher/instancingUtils.ts b/renderer/viewer/lib/mesher/instancingUtils.ts index 56947bee..4fa211d7 100644 --- a/renderer/viewer/lib/mesher/instancingUtils.ts +++ b/renderer/viewer/lib/mesher/instancingUtils.ts @@ -4,21 +4,5 @@ import { WorldBlock as Block, World } from './world' export const isBlockInstanceable = (world: World, block: Block): boolean => { // Use dynamic instanceable blocks data if available const instancedBlocks = world?.instancedBlocks - if (Array.isArray(instancedBlocks)) { - if (!instancedBlocks.includes(block.name)) return false - } else { - return false - } - - // Check if it's actually a full cube (no rotations, no complex models) - if (!block.models || block.models.length !== 1) return false - - const model = block.models[0][0] // First variant of first model - if (!model || model.x || model.y || model.z) return false // No rotations - - // Check if all elements are full cubes - return (model.elements ?? []).every(element => { - return element.from[0] === 0 && element.from[1] === 0 && element.from[2] === 0 && - element.to[0] === 16 && element.to[1] === 16 && element.to[2] === 16 - }) + return instancedBlocks?.includes(block.name) ?? false } diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index c2b76bce..b4aa927b 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -135,7 +135,11 @@ const getVec = (v: Vec3, dir: Vec3) => { return v.plus(dir) } -function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: MesherGeometryOutput, isRealWater: boolean) { +function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: MesherGeometryOutput, isRealWater: boolean, instancingEnabled: boolean) { + if (instancingEnabled) { + return // todo + } + const heights: number[] = [] for (let z = -1; z <= 1; z++) { for (let x = -1; x <= 1; x++) { @@ -557,7 +561,7 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W // Check if instanced rendering is enabled for this section const enableInstancedRendering = instancingMode !== InstancingMode.None - const forceInstancedOnly = instancingMode === InstancingMode.TexturedInstancing // Only force when using full instancing + const forceInstancedOnly = instancingMode === InstancingMode.BlockInstancingOnly || instancingMode === InstancingMode.ColorOnly const attr: MesherGeometryOutput = { sx: sx + 8, @@ -644,11 +648,11 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W const pos = cursor.clone() // eslint-disable-next-line @typescript-eslint/no-loop-func delayedRender.push(() => { - renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr, !isWaterlogged) + renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr, !isWaterlogged, forceInstancedOnly) }) attr.blocksCount++ } else if (block.name === 'lava') { - renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr, false) + renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr, false, forceInstancedOnly) attr.blocksCount++ } if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) { @@ -661,26 +665,26 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W } const blockKey = block.name - if (!attr.instancedBlocks[blockKey]) { - // Get or create block ID - const blockId = world.instancedBlockIds[block.stateId] - - if (blockId !== undefined) { - attr.instancedBlocks[blockKey] = { - blockId, - blockName: block.name, - stateId: block.stateId, - positions: [] + const blockId = world.instancedBlockIds[block.stateId] + if (blockId !== undefined) { + if (!attr.instancedBlocks[blockKey]) { + if (blockId !== undefined) { + attr.instancedBlocks[blockKey] = { + blockId, + blockName: block.name, + stateId: block.stateId, + positions: [] + } } } + attr.instancedBlocks[blockKey].positions.push({ + x: cursor.x, + y: cursor.y, + z: cursor.z + }) + attr.blocksCount++ + continue // Skip regular geometry generation for instanceable blocks } - attr.instancedBlocks[blockKey].positions.push({ - x: cursor.x, - y: cursor.y, - z: cursor.z - }) - attr.blocksCount++ - continue // Skip regular geometry generation for instanceable blocks } // Skip buffer geometry generation if force instanced only mode is enabled diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index aa6fcd84..32b2e703 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -3,7 +3,8 @@ import { BlockType } from '../../../playground/shared' export enum InstancingMode { None = 'none', ColorOnly = 'color_only', - TexturedInstancing = 'textured_instancing' + BlockInstancing = 'block_instancing', + BlockInstancingOnly = 'block_instancing_only' } // only here for easier testing diff --git a/renderer/viewer/three/entities.ts b/renderer/viewer/three/entities.ts index c05aac99..4f2e4eb3 100644 --- a/renderer/viewer/three/entities.ts +++ b/renderer/viewer/three/entities.ts @@ -1327,12 +1327,12 @@ export class Entities { } } - raycastSceneDebug () { + debugRaycastScene () { // return any object from scene. raycast from camera const raycaster = new THREE.Raycaster() raycaster.setFromCamera(new THREE.Vector2(0, 0), this.worldRenderer.camera) - const intersects = raycaster.intersectObjects(this.worldRenderer.scene.children) - return intersects[0]?.object + const intersects = raycaster.intersectObjects(this.worldRenderer.scene.children.filter(child => child.visible)) + return intersects.find(intersect => intersect.object.visible)?.object } private setupPlayerObject (entity: SceneEntity['originalEntity'], wrapper: THREE.Group, overrides: { texture?: string }): PlayerObjectType { diff --git a/renderer/viewer/three/holdingBlock.ts b/renderer/viewer/three/holdingBlock.ts index af355f2e..5d22c2bb 100644 --- a/renderer/viewer/three/holdingBlock.ts +++ b/renderer/viewer/three/holdingBlock.ts @@ -189,7 +189,7 @@ export default class HoldingBlock { this.swingAnimator?.stopSwing() } - render (originalCamera: THREE.PerspectiveCamera, renderer: THREE.WebGLRenderer, ambientLight: THREE.AmbientLight, directionalLight: THREE.DirectionalLight) { + render (renderer: THREE.WebGLRenderer) { if (!this.lastHeldItem) return const now = performance.now() if (this.lastUpdate && now - this.lastUpdate > 50) { // one tick @@ -205,15 +205,13 @@ export default class HoldingBlock { this.blockSwapAnimation?.switcher.update() - const scene = new THREE.Scene() + const scene = this.worldRenderer.templateScene scene.add(this.cameraGroup) // if (this.camera.aspect !== originalCamera.aspect) { // this.camera.aspect = originalCamera.aspect // this.camera.updateProjectionMatrix() // } this.updateCameraGroup() - scene.add(ambientLight.clone()) - scene.add(directionalLight.clone()) const viewerSize = renderer.getSize(new THREE.Vector2()) const minSize = Math.min(viewerSize.width, viewerSize.height) @@ -241,6 +239,8 @@ export default class HoldingBlock { if (offHandDisplay) { this.cameraGroup.scale.x = 1 } + + scene.remove(this.cameraGroup) } // worldTest () { diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index 9d45f21f..bd9ad580 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -1,7 +1,7 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' import { versionToNumber } from 'flying-squid/dist/utils' -import PrismarineBlock, { Block } from 'prismarine-block' +import PrismarineBlock from 'prismarine-block' import { IndexedBlock } from 'minecraft-data' import moreBlockData from '../lib/moreBlockDataGenerated.json' import { MesherGeometryOutput } from '../lib/mesher/shared' @@ -45,24 +45,32 @@ export interface InstancedBlockModelData { export interface InstancedBlocksConfig { instanceableBlocks: Set blocksDataModel: Record - allBlocksStateIdToModelIdMap: Record + stateIdToModelIdMap: Record + blockNameToIdMap: Record interestedTextureTiles: Set } export class InstancedRenderer { private readonly instancedMeshes = new Map() + private readonly sceneUsedMeshes = new Map() private readonly blockCounts = new Map() private readonly sectionInstances = new Map>() private readonly cubeGeometry: THREE.BoxGeometry private readonly tempMatrix = new THREE.Matrix4() private readonly blockIdToName = new Map() - private readonly blockNameToId = new Map() - private nextBlockId = 0 // Dynamic instance management - private readonly baseInstancesPerBlock = 100_000 // Base instances per block type - private readonly maxTotalInstances = 10_000_000 // Total instance budget across all blocks + private readonly initialInstancesPerBlock = 15_000 // Increased initial size to reduce early resizing + private readonly maxInstancesPerBlock = 100_000 // Cap per block type + private readonly maxTotalInstances = 10_000_000 // Total instance budget private currentTotalInstances = 0 + private readonly growthFactor = 1.5 // How much to grow when needed + + // Visibility control + private _instancedMeshesVisible = true + + // Memory tracking + private totalAllocatedInstances = 0 // New properties for dynamic block detection private instancedBlocksConfig: InstancedBlocksConfig | null = null @@ -72,31 +80,176 @@ export class InstancedRenderer { this.cubeGeometry = this.createCubeGeometry() } - private getMaxInstancesPerBlock (): number { - const renderDistance = this.worldRenderer.viewDistance - if (renderDistance <= 0) return this.baseInstancesPerBlock + private getBlockId (blockName: string): number { + if (!this.instancedBlocksConfig) { + throw new Error('Instanced blocks config not prepared') + } - // Calculate dynamic limit based on render distance - // More render distance = more chunks = need more instances - const distanceFactor = Math.max(1, renderDistance / 8) // Normalize around render distance 8 - const dynamicLimit = Math.floor(this.baseInstancesPerBlock * distanceFactor) + const blockId = this.instancedBlocksConfig.blockNameToIdMap[blockName] + if (blockId === undefined) { + throw new Error(`Block ${blockName} not found in blockNameToIdMap`) + } - // Cap at reasonable limits to prevent memory issues - return Math.min(dynamicLimit, 500_000) + return blockId + } + + // Add getter/setter for visibility + get instancedMeshesVisible (): boolean { + return this._instancedMeshesVisible + } + + set instancedMeshesVisible (visible: boolean) { + this._instancedMeshesVisible = visible + // Update all instanced meshes visibility + for (const mesh of this.instancedMeshes.values()) { + mesh.visible = visible + } + } + + private getInitialInstanceCount (blockName: string): number { + // Start with small allocation, can grow later if needed + return Math.min(this.initialInstancesPerBlock, this.maxInstancesPerBlock) + } + + debugResizeMesh () { + // Debug helper to test resize operation + const blockName = 'grass_block' + const blockId = this.getBlockId(blockName) + const mesh = this.instancedMeshes.get(blockId) + if (!mesh) { + console.warn('Debug: Mesh not found for', blockName) + return + } + console.log('Debug: Before resize -', + 'Mesh in scene:', + this.worldRenderer.scene.children.includes(mesh), + 'Instance count:', + mesh.count, + 'Matrix count:', + mesh.instanceMatrix.count) + + const result = this.resizeInstancedMesh(blockId, mesh.instanceMatrix.count * 2) + + const newMesh = this.instancedMeshes.get(blockId) + console.log('Debug: After resize -', + 'Success:', + result, + 'New mesh in scene:', + this.worldRenderer.scene.children.includes(newMesh!), + 'Old mesh in scene:', + this.worldRenderer.scene.children.includes(mesh), + 'New count:', + newMesh?.count, + 'New matrix count:', + newMesh?.instanceMatrix.count) + } + + private resizeInstancedMesh (blockId: number, newSize: number): boolean { + const mesh = this.instancedMeshes.get(blockId) + if (!mesh) return false + + const blockName = this.blockIdToName.get(blockId) || 'unknown' + const oldSize = mesh.instanceMatrix.count + const actualInstanceCount = this.blockCounts.get(blockId) || 0 + + console.log(`Growing instances for ${blockName}: ${oldSize} -> ${newSize} (${((newSize / oldSize - 1) * 100).toFixed(1)}% increase)`) + console.log(`Current actual instances: ${actualInstanceCount}, Is old mesh in scene:`, this.worldRenderer.scene.children.includes(mesh)) + + const { geometry } = mesh + const { material } = mesh + + // Create new mesh with increased capacity + const newMesh = new THREE.InstancedMesh( + geometry, + material, + newSize + ) + newMesh.name = mesh.name + newMesh.frustumCulled = false + newMesh.visible = this._instancedMeshesVisible + + // Copy ALL existing instances using our tracked count + for (let i = 0; i < actualInstanceCount; i++) { + this.tempMatrix.identity() + mesh.getMatrixAt(i, this.tempMatrix) + newMesh.setMatrixAt(i, this.tempMatrix) + } + + // Set the count to match our tracked instances + newMesh.count = actualInstanceCount + newMesh.instanceMatrix.needsUpdate = true + + // Update tracking + this.totalAllocatedInstances += (newSize - oldSize) + + // Important: Add new mesh before removing old one + this.worldRenderer.scene.add(newMesh) + console.log(`Added new mesh to scene, is present:`, this.worldRenderer.scene.children.includes(newMesh)) + + // Update our tracking to point to new mesh + this.instancedMeshes.set(blockId, newMesh) + + // Now safe to remove old mesh + this.worldRenderer.scene.remove(mesh) + console.log(`Removed old mesh from scene, still present:`, this.worldRenderer.scene.children.includes(mesh)) + + // Clean up old mesh + mesh.geometry.dispose() + if (Array.isArray(mesh.material)) { + for (const m of mesh.material) m.dispose() + } else { + mesh.material.dispose() + } + + // Verify instance count matches + console.log(`Finished growing ${blockName}. Actual instances: ${actualInstanceCount}, New capacity: ${newSize}, Mesh count: ${newMesh.count}`) + console.log(`Scene check - New mesh in scene: ${this.worldRenderer.scene.children.includes(newMesh)}, Old mesh in scene: ${this.worldRenderer.scene.children.includes(mesh)}`) + + if (newMesh.count !== actualInstanceCount) { + console.warn(`Instance count mismatch for ${blockName}! Mesh: ${newMesh.count}, Tracked: ${actualInstanceCount}`) + } + + return true } private canAddMoreInstances (blockId: number, count: number): boolean { const currentForBlock = this.blockCounts.get(blockId) || 0 - const maxPerBlock = this.getMaxInstancesPerBlock() + const mesh = this.instancedMeshes.get(blockId) + if (!mesh) return false - // Check per-block limit - if (currentForBlock + count > maxPerBlock) { - return false + const blockName = this.blockIdToName.get(blockId) || 'unknown' + + // If we would exceed current capacity, try to grow + if (currentForBlock + count > mesh.instanceMatrix.count) { + const currentCapacity = mesh.instanceMatrix.count + const neededCapacity = currentForBlock + count + const newSize = Math.min( + this.maxInstancesPerBlock, + Math.ceil(Math.max( + neededCapacity, + currentCapacity * this.growthFactor + )) + ) + + console.log(`Need to grow ${blockName}: current ${currentForBlock}/${currentCapacity}, need ${neededCapacity}, growing to ${newSize}`) + + // Check if growth would exceed total budget + const growthAmount = newSize - currentCapacity + if (this.totalAllocatedInstances + growthAmount > this.maxTotalInstances) { + console.warn(`Cannot grow instances for ${blockName}: would exceed total budget`) + return false + } + + // Try to grow + if (!this.resizeInstancedMesh(blockId, newSize)) { + console.warn(`Failed to grow instances for ${blockName}`) + return false + } } // Check total instance budget if (this.currentTotalInstances + count > this.maxTotalInstances) { - console.warn(`Total instance limit reached (${this.currentTotalInstances}/${this.maxTotalInstances}). Consider reducing render distance.`) + console.warn(`Total instance limit reached (${this.currentTotalInstances}/${this.maxTotalInstances})`) return false } @@ -114,7 +267,8 @@ export class InstancedRenderer { }) this.sharedMaterial.map = this.worldRenderer.material.map - const blocksMap = { + const { forceInstancedOnly } = this.worldRenderer.worldRendererConfig + const debugBlocksMap = forceInstancedOnly ? { 'double_stone_slab': 'stone', 'stone_slab': 'stone', 'oak_stairs': 'planks', @@ -133,7 +287,7 @@ export class InstancedRenderer { 'stone_slab2': 'stone_slab', 'purpur_stairs': 'purpur_block', 'purpur_slab': 'purpur_block', - } + } : {} const isPreflat = versionToNumber(this.worldRenderer.version) < versionToNumber('1.13') const PBlockOriginal = PrismarineBlock(this.worldRenderer.version) @@ -141,9 +295,9 @@ export class InstancedRenderer { const instanceableBlocks = new Set() const blocksDataModel = {} as Record const interestedTextureTiles = new Set() - const blocksProcessed = {} as Record - let i = 0 - const allBlocksStateIdToModelIdMap = {} as Record + const stateIdToModelIdMap = {} as Record + const blockNameToIdMap = {} as Record + let nextModelId = 0 const addBlockModel = (state: number, name: string, props: Record, mcBlockData?: IndexedBlock, defaultState = false) => { const possibleIssues = [] as string[] @@ -184,7 +338,7 @@ export class InstancedRenderer { textureInfos: Array.from({ length: 6 }).fill(null).map(() => ({ u: 0, v: 0, su: 0, sv: 0 })) } - const blockId = i++ + const blockId = nextModelId++ for (const [face, { texture, cullface, rotation = 0 }] of Object.entries(elem.faces)) { const faceIndex = facesMapping.findIndex(x => x.includes(face)) if (faceIndex === -1) { @@ -207,10 +361,10 @@ export class InstancedRenderer { } } - allBlocksStateIdToModelIdMap[state] = blockId + stateIdToModelIdMap[state] = blockId blocksDataModel[blockId] = blockData instanceableBlocks.add(name) - blocksProcessed[name] = true + blockNameToIdMap[name] = blockId if (mcBlockData) { blockData.transparent = mcBlockData.transparent @@ -231,7 +385,7 @@ export class InstancedRenderer { // Process all blocks to find instanceable ones for (const b of (globalThis as any).loadedData.blocksArray) { for (let state = b.minStateId; state <= b.maxStateId; state++) { - const mapping = blocksMap[b.name] + const mapping = debugBlocksMap[b.name] const block = PBlockOriginal.fromStateId(mapping && (globalThis as any).loadedData.blocksByName[mapping] ? (globalThis as any).loadedData.blocksByName[mapping].defaultState : state, 0) if (isPreflat) { getPreflatBlock(block) @@ -239,7 +393,7 @@ export class InstancedRenderer { const textureOverride = textureOverrideFullBlocks[block.name] as string | undefined if (textureOverride) { - const blockId = i++ + const blockId = nextModelId++ const { currentResources } = this.worldRenderer.resourcesManager if (!currentResources?.worldBlockProvider) continue const texture = currentResources.worldBlockProvider.getTextureInfo(textureOverride) @@ -248,7 +402,7 @@ export class InstancedRenderer { continue } const texIndex = texture.tileIndex - allBlocksStateIdToModelIdMap[state] = blockId + stateIdToModelIdMap[state] = blockId const blockData: InstancedBlockModelData = { textures: [texIndex, texIndex, texIndex, texIndex, texIndex, texIndex], rotation: [0, 0, 0, 0, 0, 0], @@ -263,6 +417,7 @@ export class InstancedRenderer { blocksDataModel[blockId] = blockData instanceableBlocks.add(block.name) interestedTextureTiles.add(textureOverride) + blockNameToIdMap[block.name] = blockId continue } @@ -280,7 +435,8 @@ export class InstancedRenderer { return { instanceableBlocks, blocksDataModel, - allBlocksStateIdToModelIdMap, + stateIdToModelIdMap, + blockNameToIdMap, interestedTextureTiles } } @@ -299,6 +455,7 @@ export class InstancedRenderer { } } + // Update initializeInstancedMeshes to respect visibility setting initializeInstancedMeshes () { if (!this.instancedBlocksConfig) { console.warn('Instanced blocks config not prepared') @@ -311,6 +468,7 @@ export class InstancedRenderer { if (this.instancedMeshes.has(blockId)) continue // Skip if already exists const blockModelData = this.instancedBlocksConfig.blocksDataModel[blockId] + const initialCount = this.getInitialInstanceCount(blockName) const geometry = blockModelData ? this.createCustomGeometry(0, blockModelData) : this.cubeGeometry const material = this.createBlockMaterial(blockName) @@ -318,14 +476,16 @@ export class InstancedRenderer { const mesh = new THREE.InstancedMesh( geometry, material, - this.getMaxInstancesPerBlock() + initialCount ) mesh.name = `instanced_${blockName}` - mesh.frustumCulled = false // Important for performance + mesh.frustumCulled = false mesh.count = 0 + mesh.visible = this._instancedMeshesVisible // Set initial visibility this.instancedMeshes.set(blockId, mesh) this.worldRenderer.scene.add(mesh) + this.totalAllocatedInstances += initialCount if (!blockModelData) { console.warn(`No block model data found for block ${blockName}`) @@ -333,6 +493,19 @@ export class InstancedRenderer { } } + private debugRaycast () { + // get instanced block name + const raycaster = new THREE.Raycaster() + raycaster.setFromCamera(new THREE.Vector2(0, 0), this.worldRenderer.camera) + const intersects = raycaster.intersectObjects(this.worldRenderer.scene.children.filter(child => child.visible)) + for (const intersect of intersects) { + const mesh = intersect.object as THREE.Mesh + if (mesh.name.startsWith('instanced_')) { + console.log(`Instanced block name: ${mesh.name}`) + } + } + } + private createCubeGeometry (): THREE.BoxGeometry { // Create a basic cube geometry // For proper texturing, we would need to modify UV coordinates per block type @@ -475,7 +648,7 @@ export class InstancedRenderer { // Add new instances for this section (with limit checking) for (const pos of blockData.positions) { if (!this.canAddMoreInstances(blockId, 1)) { - console.warn(`Exceeded max instances for block ${blockName} (${currentCount + instanceIndices.length}/${this.getMaxInstancesPerBlock()})`) + console.warn(`Exceeded max instances for block ${blockName} (${currentCount + instanceIndices.length}/${this.maxInstancesPerBlock})`) break } @@ -488,10 +661,16 @@ export class InstancedRenderer { // Update tracking if (instanceIndices.length > 0) { sectionMap.set(blockId, instanceIndices) - this.blockCounts.set(blockId, currentCount + instanceIndices.length) + const newCount = currentCount + instanceIndices.length + this.blockCounts.set(blockId, newCount) this.currentTotalInstances += instanceIndices.length - mesh.count = this.blockCounts.get(blockId) || 0 + mesh.count = newCount // Ensure mesh.count matches our tracking mesh.instanceMatrix.needsUpdate = true + + // Only track mesh in sceneUsedMeshes if it's actually being used + if (newCount > 0) { + this.sceneUsedMeshes.set(blockName, mesh) + } } } } @@ -503,6 +682,12 @@ export class InstancedRenderer { // Remove instances for each block type in this section for (const [blockId, instanceIndices] of sectionMap) { this.removeInstancesFromBlock(blockId, instanceIndices) + + // Remove from sceneUsedMeshes if no instances left + const blockName = this.blockIdToName.get(blockId) + if (blockName && (this.blockCounts.get(blockId) || 0) === 0) { + this.sceneUsedMeshes.delete(blockName) + } } // Remove section from tracking @@ -557,6 +742,14 @@ export class InstancedRenderer { } } } + + // Update sceneUsedMeshes if no instances left + if (newCount === 0) { + const blockName = this.blockIdToName.get(blockId) + if (blockName) { + this.sceneUsedMeshes.delete(blockName) + } + } } isBlockInstanceable (blockName: string): boolean { @@ -593,49 +786,50 @@ export class InstancedRenderer { this.blockCounts.clear() this.sectionInstances.clear() this.blockIdToName.clear() - this.blockNameToId.clear() - this.nextBlockId = 0 + this.sceneUsedMeshes.clear() this.cubeGeometry.dispose() } + // Add visibility info to stats getStats () { let totalInstances = 0 let activeBlockTypes = 0 + let totalWastedMemory = 0 for (const [blockId, mesh] of this.instancedMeshes) { - if (mesh.count > 0) { - totalInstances += mesh.count + const allocated = mesh.instanceMatrix.count + const used = mesh.count + totalWastedMemory += (allocated - used) * 64 // 64 bytes per instance (approximate) + + if (used > 0) { + totalInstances += used activeBlockTypes++ } } - const maxPerBlock = this.getMaxInstancesPerBlock() + const maxPerBlock = this.maxInstancesPerBlock const renderDistance = this.worldRenderer.viewDistance return { totalInstances, activeBlockTypes, - drawCalls: activeBlockTypes, // One draw call per active block type - memoryEstimate: totalInstances * 64, // Rough estimate in bytes + drawCalls: this._instancedMeshesVisible ? activeBlockTypes : 0, + memoryStats: { + totalAllocatedInstances: this.totalAllocatedInstances, + usedInstances: totalInstances, + wastedInstances: this.totalAllocatedInstances - totalInstances, + estimatedMemoryUsage: this.totalAllocatedInstances * 64, + estimatedWastedMemory: totalWastedMemory, + utilizationPercent: ((totalInstances / this.totalAllocatedInstances) * 100).toFixed(1) + '%' + }, maxInstancesPerBlock: maxPerBlock, totalInstanceBudget: this.maxTotalInstances, renderDistance, - instanceUtilization: totalInstances / this.maxTotalInstances + instanceUtilization: totalInstances / this.maxTotalInstances, + instancedMeshesVisible: this._instancedMeshesVisible } } - private getBlockId (blockName: string): number { - // Get the block ID from our local map - let blockId = this.blockNameToId.get(blockName) - if (blockId === undefined) { - // If the block ID doesn't exist, create a new one - blockId = this.nextBlockId++ - this.blockNameToId.set(blockName, blockId) - this.blockIdToName.set(blockId, blockName) - } - return blockId - } - // New method to prepare and initialize everything prepareAndInitialize () { console.log('Preparing instanced blocks data...') diff --git a/renderer/viewer/three/threeJsMedia.ts b/renderer/viewer/three/threeJsMedia.ts index 582273d1..92599c8d 100644 --- a/renderer/viewer/three/threeJsMedia.ts +++ b/renderer/viewer/three/threeJsMedia.ts @@ -565,7 +565,7 @@ export class ThreeJsMedia { raycaster.setFromCamera(mouse, camera) // Check intersection with all objects in scene - const intersects = raycaster.intersectObjects(scene.children, true) + const intersects = raycaster.intersectObjects(scene.children.filter(child => child.visible), true) if (intersects.length > 0) { const intersection = intersects[0] const intersectedObject = intersection.object diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index b0f880ad..9116e289 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -38,6 +38,7 @@ export class WorldRendererThree extends WorldRendererCommon { holdingBlock: HoldingBlock holdingBlockLeft: HoldingBlock scene = new THREE.Scene() + templateScene = new THREE.Scene() ambientLight = new THREE.AmbientLight(0xcc_cc_cc) directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5) entities = new Entities(this) @@ -149,7 +150,7 @@ export class WorldRendererThree extends WorldRendererCommon { return { instanceableBlocks: config.instanceableBlocks, - allBlocksStateIdToModelIdMap: config.allBlocksStateIdToModelIdMap + allBlocksStateIdToModelIdMap: config.stateIdToModelIdMap } } @@ -157,6 +158,12 @@ export class WorldRendererThree extends WorldRendererCommon { this.entities.handlePlayerEntity(e) } + resetTemplateScene () { + this.templateScene = new THREE.Scene() + this.templateScene.add(this.ambientLight.clone()) + this.templateScene.add(this.directionalLight.clone()) + } + resetScene () { this.scene.matrixAutoUpdate = false // for perf this.scene.background = new THREE.Color(this.initOptions.config.sceneBackground) @@ -170,6 +177,8 @@ export class WorldRendererThree extends WorldRendererCommon { this.cameraContainer = new THREE.Object3D() this.cameraContainer.add(this.camera) this.scene.add(this.cameraContainer) + + this.resetTemplateScene() } override watchReactivePlayerState () { @@ -180,10 +189,12 @@ export class WorldRendererThree extends WorldRendererCommon { this.onReactivePlayerStateUpdated('ambientLight', (value) => { if (!value) return this.ambientLight.intensity = value + this.resetTemplateScene() }) this.onReactivePlayerStateUpdated('directionalLight', (value) => { if (!value) return this.directionalLight.intensity = value + this.resetTemplateScene() }) this.onReactivePlayerStateUpdated('lookingAtBlock', (value) => { this.cursorBlock.setHighlightCursorBlock(value ? new Vec3(value.x, value.y, value.z) : null, value?.shapes) @@ -757,8 +768,8 @@ export class WorldRendererThree extends WorldRendererCommon { // !this.freeFlyMode && !this.renderer.xr.isPresenting ) { - this.holdingBlock.render(this.camera, this.renderer, this.ambientLight, this.directionalLight) - this.holdingBlockLeft.render(this.camera, this.renderer, this.ambientLight, this.directionalLight) + this.holdingBlock.render(this.renderer) + this.holdingBlockLeft.render(this.renderer) } for (const fountain of this.fountains) { @@ -955,11 +966,15 @@ export class WorldRendererThree extends WorldRendererCommon { } setSectionDirty (pos: Vec3, value = true) { - const { useInstancedRendering, enableSingleColorMode } = this.worldRendererConfig + const { useInstancedRendering, enableSingleColorMode, forceInstancedOnly } = this.worldRendererConfig let instancingMode = InstancingMode.None - if (useInstancedRendering) { - instancingMode = enableSingleColorMode ? InstancingMode.ColorOnly : InstancingMode.TexturedInstancing + if (useInstancedRendering || enableSingleColorMode) { + instancingMode = enableSingleColorMode + ? InstancingMode.ColorOnly + : forceInstancedOnly + ? InstancingMode.BlockInstancingOnly + : InstancingMode.BlockInstancing } this.cleanChunkTextures(pos.x, pos.z) // todo don't do this! From 3c358c9d2245d68b4f0f52069c0aadbe63977fa7 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 06:46:50 +0300 Subject: [PATCH 11/86] all done! --- renderer/viewer/three/instancedRenderer.ts | 66 ++++------------------ src/optionsGuiScheme.tsx | 44 +++++++++++++++ 2 files changed, 56 insertions(+), 54 deletions(-) diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index bd9ad580..3c0efd64 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -60,7 +60,7 @@ export class InstancedRenderer { private readonly blockIdToName = new Map() // Dynamic instance management - private readonly initialInstancesPerBlock = 15_000 // Increased initial size to reduce early resizing + private readonly initialInstancesPerBlock = 2000 // Increased initial size to reduce early resizing private readonly maxInstancesPerBlock = 100_000 // Cap per block type private readonly maxTotalInstances = 10_000_000 // Total instance budget private currentTotalInstances = 0 @@ -116,32 +116,7 @@ export class InstancedRenderer { const blockName = 'grass_block' const blockId = this.getBlockId(blockName) const mesh = this.instancedMeshes.get(blockId) - if (!mesh) { - console.warn('Debug: Mesh not found for', blockName) - return - } - console.log('Debug: Before resize -', - 'Mesh in scene:', - this.worldRenderer.scene.children.includes(mesh), - 'Instance count:', - mesh.count, - 'Matrix count:', - mesh.instanceMatrix.count) - - const result = this.resizeInstancedMesh(blockId, mesh.instanceMatrix.count * 2) - - const newMesh = this.instancedMeshes.get(blockId) - console.log('Debug: After resize -', - 'Success:', - result, - 'New mesh in scene:', - this.worldRenderer.scene.children.includes(newMesh!), - 'Old mesh in scene:', - this.worldRenderer.scene.children.includes(mesh), - 'New count:', - newMesh?.count, - 'New matrix count:', - newMesh?.instanceMatrix.count) + this.resizeInstancedMesh(blockId, mesh!.instanceMatrix.count * this.growthFactor) } private resizeInstancedMesh (blockId: number, newSize: number): boolean { @@ -153,7 +128,6 @@ export class InstancedRenderer { const actualInstanceCount = this.blockCounts.get(blockId) || 0 console.log(`Growing instances for ${blockName}: ${oldSize} -> ${newSize} (${((newSize / oldSize - 1) * 100).toFixed(1)}% increase)`) - console.log(`Current actual instances: ${actualInstanceCount}, Is old mesh in scene:`, this.worldRenderer.scene.children.includes(mesh)) const { geometry } = mesh const { material } = mesh @@ -175,23 +149,14 @@ export class InstancedRenderer { newMesh.setMatrixAt(i, this.tempMatrix) } - // Set the count to match our tracked instances newMesh.count = actualInstanceCount newMesh.instanceMatrix.needsUpdate = true - // Update tracking this.totalAllocatedInstances += (newSize - oldSize) - // Important: Add new mesh before removing old one this.worldRenderer.scene.add(newMesh) - console.log(`Added new mesh to scene, is present:`, this.worldRenderer.scene.children.includes(newMesh)) - - // Update our tracking to point to new mesh this.instancedMeshes.set(blockId, newMesh) - - // Now safe to remove old mesh this.worldRenderer.scene.remove(mesh) - console.log(`Removed old mesh from scene, still present:`, this.worldRenderer.scene.children.includes(mesh)) // Clean up old mesh mesh.geometry.dispose() @@ -203,11 +168,6 @@ export class InstancedRenderer { // Verify instance count matches console.log(`Finished growing ${blockName}. Actual instances: ${actualInstanceCount}, New capacity: ${newSize}, Mesh count: ${newMesh.count}`) - console.log(`Scene check - New mesh in scene: ${this.worldRenderer.scene.children.includes(newMesh)}, Old mesh in scene: ${this.worldRenderer.scene.children.includes(mesh)}`) - - if (newMesh.count !== actualInstanceCount) { - console.warn(`Instance count mismatch for ${blockName}! Mesh: ${newMesh.count}, Tracked: ${actualInstanceCount}`) - } return true } @@ -636,22 +596,20 @@ export class InstancedRenderer { const { blockId, stateId } = blockData this.blockIdToName.set(blockId, blockName) - const mesh = this.instancedMeshes.get(blockId) - if (!mesh) { - console.warn(`Failed to find mesh for block ${blockName}`) - continue - } - const instanceIndices: number[] = [] const currentCount = this.blockCounts.get(blockId) || 0 - // Add new instances for this section (with limit checking) - for (const pos of blockData.positions) { - if (!this.canAddMoreInstances(blockId, 1)) { - console.warn(`Exceeded max instances for block ${blockName} (${currentCount + instanceIndices.length}/${this.maxInstancesPerBlock})`) - break - } + // Check if we can add all positions at once + const neededInstances = blockData.positions.length + if (!this.canAddMoreInstances(blockId, neededInstances)) { + console.warn(`Cannot add ${neededInstances} instances for block ${blockName} (current: ${currentCount}, max: ${this.maxInstancesPerBlock})`) + continue + } + const mesh = this.instancedMeshes.get(blockId)! + + // Add new instances for this section + for (const pos of blockData.positions) { const instanceIndex = currentCount + instanceIndices.length this.tempMatrix.setPosition(pos.x + 0.5, pos.y + 0.5, pos.z + 0.5) mesh.setMatrixAt(instanceIndex, this.tempMatrix) diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index b03db37d..1beadb07 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -114,6 +114,50 @@ export const guiOptionsScheme: { text: 'Performance Debug', } }, + { + custom () { + let status = 'OFF' + const { useInstancedRendering, forceInstancedOnly, enableSingleColorMode } = useSnapshot(options) + if (useInstancedRendering) { + status = 'ON' + if (enableSingleColorMode) { + status = 'ON (single color)' + } else if (forceInstancedOnly) { + status = 'ON (force)' + } + } + + return + }, + }, { custom () { const { _renderByChunks } = useSnapshot(options).rendererSharedOptions From 336aad678bddac9bea83b2087b59e190f74151e4 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 07:52:17 +0300 Subject: [PATCH 12/86] real --- README.MD | 1 - renderer/viewer/lib/mesher/mesher.ts | 3 +-- renderer/viewer/three/entities.ts | 1 + renderer/viewer/three/instancedRenderer.ts | 27 ++++++++++++++++++--- renderer/viewer/three/worldrendererThree.ts | 6 ++--- 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/README.MD b/README.MD index 61a5b733..9bae3e56 100644 --- a/README.MD +++ b/README.MD @@ -39,7 +39,6 @@ All components that are in [Storybook](https://minimap.mcraft.fun/storybook/) ar - Controls -> **Touch Controls Type** -> **Joystick** - Controls -> **Auto Full Screen** -> **On** - To avoid ctrl+w issue -- Interface -> **Enable Minimap** -> **Always** - To enable useful minimap (why not?) - Controls -> **Raw Input** -> **On** - This will make the controls more precise (UPD: already enabled by default) - Interface -> **Chat Select** -> **On** - To select chat messages (UPD: already enabled by default) diff --git a/renderer/viewer/lib/mesher/mesher.ts b/renderer/viewer/lib/mesher/mesher.ts index f781275c..daf0b8ab 100644 --- a/renderer/viewer/lib/mesher/mesher.ts +++ b/renderer/viewer/lib/mesher/mesher.ts @@ -68,8 +68,7 @@ function setSectionDirty (pos, value = true, instancingMode = InstancingMode.Non const softCleanup = () => { // clean block cache and loaded chunks - world = new World(world.config.version) - globalThis.world = world + world.blockCache = {} } const handleMessage = data => { diff --git a/renderer/viewer/three/entities.ts b/renderer/viewer/three/entities.ts index 4f2e4eb3..7e04370f 100644 --- a/renderer/viewer/three/entities.ts +++ b/renderer/viewer/three/entities.ts @@ -730,6 +730,7 @@ export class Entities { outerGroup.add(mesh) return { mesh: outerGroup, + meshGeometry: mesh.children.find(child => child instanceof THREE.Mesh)?.geometry, isBlock: true, itemsTexture: null, itemsTextureFlipped: null, diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index 3c0efd64..4c149147 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -34,6 +34,7 @@ export interface InstancedSectionData { } export interface InstancedBlockModelData { + stateId: number textures: number[] rotation: number[] transparent?: boolean @@ -51,6 +52,7 @@ export interface InstancedBlocksConfig { } export class InstancedRenderer { + USE_APP_GEOMETRY = true private readonly instancedMeshes = new Map() private readonly sceneUsedMeshes = new Map() private readonly blockCounts = new Map() @@ -78,6 +80,7 @@ export class InstancedRenderer { constructor (private readonly worldRenderer: WorldRendererThree) { this.cubeGeometry = this.createCubeGeometry() + // Initialize world origin at camera position } private getBlockId (blockName: string): number { @@ -293,6 +296,7 @@ export class InstancedRenderer { ] const blockData: InstancedBlockModelData = { + stateId: state, textures: [0, 0, 0, 0, 0, 0], rotation: [0, 0, 0, 0, 0, 0], textureInfos: Array.from({ length: 6 }).fill(null).map(() => ({ u: 0, v: 0, su: 0, sv: 0 })) @@ -364,6 +368,7 @@ export class InstancedRenderer { const texIndex = texture.tileIndex stateIdToModelIdMap[state] = blockId const blockData: InstancedBlockModelData = { + stateId: state, textures: [texIndex, texIndex, texIndex, texIndex, texIndex, texIndex], rotation: [0, 0, 0, 0, 0, 0], filterLight: b.filterLight, @@ -425,12 +430,13 @@ export class InstancedRenderer { // Create InstancedMesh for each instanceable block type for (const blockName of this.instancedBlocksConfig.instanceableBlocks) { const blockId = this.getBlockId(blockName) + const { stateId } = this.instancedBlocksConfig.blocksDataModel[blockId] if (this.instancedMeshes.has(blockId)) continue // Skip if already exists const blockModelData = this.instancedBlocksConfig.blocksDataModel[blockId] const initialCount = this.getInitialInstanceCount(blockName) - const geometry = blockModelData ? this.createCustomGeometry(0, blockModelData) : this.cubeGeometry + const geometry = blockModelData ? this.createCustomGeometry(stateId, blockModelData) : this.cubeGeometry const material = this.createBlockMaterial(blockName) const mesh = new THREE.InstancedMesh( @@ -475,6 +481,14 @@ export class InstancedRenderer { } private createCustomGeometry (stateId: number, blockModelData: InstancedBlockModelData): THREE.BufferGeometry { + if (this.USE_APP_GEOMETRY) { + const itemMesh = this.worldRenderer.entities.getItemMesh({ + blockState: stateId + }, {}) + + return itemMesh?.meshGeometry + } + // Create custom geometry with specific UV coordinates per face const geometry = new THREE.BoxGeometry(1, 1, 1) @@ -525,7 +539,9 @@ export class InstancedRenderer { const faceUVs = uvs.slice(faceUvStart, faceUvStart + 8) // Apply rotation if needed (0=0°, 1=90°, 2=180°, 3=270°) - if (rotation > 0) { + // Add base 180° rotation (2) to all faces + const totalRotation = (rotation + 2) % 4 + if (totalRotation > 0) { // Each vertex has 2 UV coordinates (u,v) // We need to rotate the 4 vertices as a group const vertices: UVVertex[] = [] @@ -539,7 +555,7 @@ export class InstancedRenderer { // Rotate vertices const rotatedVertices: UVVertex[] = [] for (let i = 0; i < 4; i++) { - const srcIndex = (i + rotation) % 4 + const srcIndex = (i + totalRotation) % 4 rotatedVertices.push(vertices[srcIndex]) } @@ -611,7 +627,10 @@ export class InstancedRenderer { // Add new instances for this section for (const pos of blockData.positions) { const instanceIndex = currentCount + instanceIndices.length - this.tempMatrix.setPosition(pos.x + 0.5, pos.y + 0.5, pos.z + 0.5) + const offset = this.USE_APP_GEOMETRY ? 0 : 0.5 + + // Calculate position relative to world origin + this.tempMatrix.makeTranslation(pos.x + offset, pos.y + offset, pos.z + offset) mesh.setMatrixAt(instanceIndex, this.tempMatrix) instanceIndices.push(instanceIndex) } diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 9116e289..849c432d 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -50,7 +50,7 @@ export class WorldRendererThree extends WorldRendererCommon { cameraShake: CameraShake cameraContainer: THREE.Object3D media: ThreeJsMedia - instancedRenderer: InstancedRenderer + instancedRenderer: InstancedRenderer | undefined waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] } camera: THREE.PerspectiveCamera renderTimeAvg = 0 @@ -496,7 +496,7 @@ export class WorldRendererThree extends WorldRendererCommon { return worldPos } - getWorldCameraPosition () { + getSectionCameraPosition () { const pos = this.getCameraPosition() return new Vec3( Math.floor(pos.x / 16), @@ -506,7 +506,7 @@ export class WorldRendererThree extends WorldRendererCommon { } updateCameraSectionPos () { - const newSectionPos = this.getWorldCameraPosition() + const newSectionPos = this.getSectionCameraPosition() if (!this.cameraSectionPos.equals(newSectionPos)) { this.cameraSectionPos = newSectionPos this.cameraSectionPositionUpdate() From dbbe5445d8aebbdbd7d5c26ff2411ce6cbe18f46 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 07:55:29 +0300 Subject: [PATCH 13/86] realScene test --- renderer/viewer/three/worldrendererThree.ts | 83 ++++++++++++++------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 849c432d..13eba9b1 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -35,9 +35,10 @@ export class WorldRendererThree extends WorldRendererCommon { signsCache = new Map() starField: StarField cameraSectionPos: Vec3 = new Vec3(0, 0, 0) - holdingBlock: HoldingBlock - holdingBlockLeft: HoldingBlock - scene = new THREE.Scene() + holdingBlock: HoldingBlock | undefined + holdingBlockLeft: HoldingBlock | undefined + realScene = new THREE.Scene() + scene = new THREE.Group() templateScene = new THREE.Scene() ambientLight = new THREE.AmbientLight(0xcc_cc_cc) directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5) @@ -76,6 +77,7 @@ export class WorldRendererThree extends WorldRendererCommon { private currentPosTween?: tweenJs.Tween private currentRotTween?: tweenJs.Tween<{ pitch: number, yaw: number }> + private readonly worldOffset = new THREE.Vector3() get tilesRendered () { return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0) @@ -114,6 +116,12 @@ export class WorldRendererThree extends WorldRendererCommon { void this.init() } + // Add this method to update world origin + private updateWorldOrigin (pos: THREE.Vector3) { + // this.worldOffset.copy(pos) + // this.scene.position.copy(this.worldOffset).multiplyScalar(-1) + } + get cameraObject () { return this.cameraGroupVr ?? this.cameraContainer } @@ -145,7 +153,7 @@ export class WorldRendererThree extends WorldRendererCommon { } getInstancedBlocksData () { - const config = this.instancedRenderer.getInstancedBlocksConfig() + const config = this.instancedRenderer?.getInstancedBlocksConfig() if (!config) return undefined return { @@ -166,17 +174,18 @@ export class WorldRendererThree extends WorldRendererCommon { resetScene () { this.scene.matrixAutoUpdate = false // for perf - this.scene.background = new THREE.Color(this.initOptions.config.sceneBackground) - this.scene.add(this.ambientLight) + this.realScene.background = new THREE.Color(this.initOptions.config.sceneBackground) + this.realScene.add(this.ambientLight) this.directionalLight.position.set(1, 1, 0.5).normalize() this.directionalLight.castShadow = true - this.scene.add(this.directionalLight) + this.realScene.add(this.directionalLight) const size = this.renderer.getSize(new THREE.Vector2()) this.camera = new THREE.PerspectiveCamera(75, size.x / size.y, 0.1, 1000) this.cameraContainer = new THREE.Object3D() this.cameraContainer.add(this.camera) - this.scene.add(this.cameraContainer) + this.realScene.add(this.cameraContainer) + this.realScene.add(this.scene) this.resetTemplateScene() } @@ -184,7 +193,7 @@ export class WorldRendererThree extends WorldRendererCommon { override watchReactivePlayerState () { super.watchReactivePlayerState() this.onReactivePlayerStateUpdated('inWater', (value) => { - this.scene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, this.playerStateReactive.waterBreathing ? 100 : 20) : null + this.realScene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, this.playerStateReactive.waterBreathing ? 100 : 20) : null }) this.onReactivePlayerStateUpdated('ambientLight', (value) => { if (!value) return @@ -220,9 +229,9 @@ export class WorldRendererThree extends WorldRendererCommon { changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) { const holdingBlock = isLeft ? this.holdingBlockLeft : this.holdingBlock if (isAnimationPlaying) { - holdingBlock.startSwing() + holdingBlock?.startSwing() } else { - holdingBlock.stopSwing() + holdingBlock?.stopSwing() } } @@ -250,7 +259,7 @@ export class WorldRendererThree extends WorldRendererCommon { } // Prepare and initialize instanced renderer with dynamic block detection - this.instancedRenderer.prepareAndInitialize() + this.instancedRenderer?.prepareAndInitialize() await super.updateAssetsData() this.onAllTexturesLoaded() @@ -262,14 +271,18 @@ export class WorldRendererThree extends WorldRendererCommon { } onAllTexturesLoaded () { - this.holdingBlock.ready = true - this.holdingBlock.updateItem() - this.holdingBlockLeft.ready = true - this.holdingBlockLeft.updateItem() + if (this.holdingBlock) { + this.holdingBlock.ready = true + this.holdingBlock.updateItem() + } + if (this.holdingBlockLeft) { + this.holdingBlockLeft.ready = true + this.holdingBlockLeft.updateItem() + } } changeBackgroundColor (color: [number, number, number]): void { - this.scene.background = new THREE.Color(color[0], color[1], color[2]) + this.realScene.background = new THREE.Color(color[0], color[1], color[2]) } timeUpdated (newTime: number): void { @@ -323,15 +336,17 @@ export class WorldRendererThree extends WorldRendererCommon { const formatBigNumber = (num: number) => { return new Intl.NumberFormat('en-US', {}).format(num) } - const instancedStats = this.instancedRenderer.getStats() + const instancedStats = this.instancedRenderer?.getStats() let text = '' text += `C: ${formatBigNumber(this.renderer.info.render.calls)} ` text += `TR: ${formatBigNumber(this.renderer.info.render.triangles)} ` text += `TE: ${formatBigNumber(this.renderer.info.memory.textures)} ` text += `F: ${formatBigNumber(this.tilesRendered)} ` text += `B: ${formatBigNumber(this.blocksRendered)} ` - text += `I: ${formatBigNumber(instancedStats.totalInstances)}/${instancedStats.activeBlockTypes}t ` - text += `DC: ${formatBigNumber(instancedStats.drawCalls)}` + if (instancedStats) { + text += `I: ${formatBigNumber(instancedStats.totalInstances)}/${instancedStats.activeBlockTypes}t ` + text += `DC: ${formatBigNumber(instancedStats.drawCalls)}` + } pane.updateText(text) this.backendInfoReport = text } @@ -382,11 +397,11 @@ export class WorldRendererThree extends WorldRendererCommon { const chunkKey = chunkCoords[0] + ',' + chunkCoords[2] // Always clear old instanced blocks for this section first, then add new ones if any - this.instancedRenderer.removeSectionInstances(data.key) + this.instancedRenderer?.removeSectionInstances(data.key) // Handle instanced blocks data from worker if (data.geometry.instancedBlocks && Object.keys(data.geometry.instancedBlocks).length > 0) { - this.instancedRenderer.handleInstancedBlocksFromWorker(data.geometry.instancedBlocks, data.key) + this.instancedRenderer?.handleInstancedBlocksFromWorker(data.geometry.instancedBlocks, data.key) } let object: THREE.Object3D = this.sectionObjects[data.key] @@ -493,7 +508,8 @@ export class WorldRendererThree extends WorldRendererCommon { getCameraPosition () { const worldPos = new THREE.Vector3() this.camera.getWorldPosition(worldPos) - return worldPos + // Add world offset to get true world position + return worldPos.add(this.worldOffset) } getSectionCameraPosition () { @@ -516,6 +532,17 @@ export class WorldRendererThree extends WorldRendererCommon { setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) { const yOffset = this.playerStateReactive.eyeHeight + if (pos) { + // Convert Vec3 to THREE.Vector3 + const worldPos = new THREE.Vector3(pos.x, pos.y + yOffset, pos.z) + + // Update world origin before updating camera + this.updateWorldOrigin(worldPos) + + // Keep camera at origin and move world instead + // this.cameraObject.position.set(pos.x, pos.y + yOffset, pos.z) + } + this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch) this.media.tryIntersectMedia() this.updateCameraSectionPos() @@ -759,7 +786,7 @@ export class WorldRendererThree extends WorldRendererCommon { // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style const cam = this.cameraGroupVr instanceof THREE.Group ? this.cameraGroupVr.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera - this.renderer.render(this.scene, cam) + this.renderer.render(this.realScene, cam) if ( this.displayOptions.inWorldRenderingConfig.showHand && @@ -768,8 +795,8 @@ export class WorldRendererThree extends WorldRendererCommon { // !this.freeFlyMode && !this.renderer.xr.isPresenting ) { - this.holdingBlock.render(this.renderer) - this.holdingBlockLeft.render(this.renderer) + this.holdingBlock?.render(this.renderer) + this.holdingBlockLeft?.render(this.renderer) } for (const fountain of this.fountains) { @@ -954,7 +981,7 @@ export class WorldRendererThree extends WorldRendererCommon { const key = `${x},${y},${z}` // Remove instanced blocks for this section - this.instancedRenderer.removeSectionInstances(key) + this.instancedRenderer?.removeSectionInstances(key) const mesh = this.sectionObjects[key] if (mesh) { @@ -995,7 +1022,7 @@ export class WorldRendererThree extends WorldRendererCommon { } destroy (): void { - this.instancedRenderer.destroy() + this.instancedRenderer?.destroy() super.destroy() } From 6868068705026c5f6c0099b92aeb162e31b17bcf Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 08:41:05 +0300 Subject: [PATCH 14/86] fixes --- renderer/viewer/three/instancedRenderer.ts | 3 +-- renderer/viewer/three/panorama.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index 4c149147..7c5ae7d8 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -629,8 +629,7 @@ export class InstancedRenderer { const instanceIndex = currentCount + instanceIndices.length const offset = this.USE_APP_GEOMETRY ? 0 : 0.5 - // Calculate position relative to world origin - this.tempMatrix.makeTranslation(pos.x + offset, pos.y + offset, pos.z + offset) + this.tempMatrix.setPosition(pos.x + offset, pos.y + offset, pos.z + offset) mesh.setMatrixAt(instanceIndex, this.tempMatrix) instanceIndices.push(instanceIndex) } diff --git a/renderer/viewer/three/panorama.ts b/renderer/viewer/three/panorama.ts index 7e975d4b..0037ff1d 100644 --- a/renderer/viewer/three/panorama.ts +++ b/renderer/viewer/three/panorama.ts @@ -203,7 +203,7 @@ export class PanoramaRenderer { } ) if (this.worldRenderer instanceof WorldRendererThree) { - this.scene = this.worldRenderer.scene + this.scene = this.worldRenderer.realScene } void worldView.init(initPos) From 83018cd828e11bd616a8adfc62445fb19b9e0e46 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 09:12:56 +0300 Subject: [PATCH 15/86] [before test] refactor to use state id, force! --- renderer/viewer/lib/mesher/instancingUtils.ts | 6 +- renderer/viewer/lib/mesher/models.ts | 33 +- renderer/viewer/lib/mesher/shared.ts | 3 +- renderer/viewer/lib/mesher/world.ts | 4 +- renderer/viewer/three/instancedRenderer.ts | 387 +++++++++--------- 5 files changed, 212 insertions(+), 221 deletions(-) diff --git a/renderer/viewer/lib/mesher/instancingUtils.ts b/renderer/viewer/lib/mesher/instancingUtils.ts index 4fa211d7..ff4bdd10 100644 --- a/renderer/viewer/lib/mesher/instancingUtils.ts +++ b/renderer/viewer/lib/mesher/instancingUtils.ts @@ -1,8 +1,8 @@ import { WorldBlock as Block, World } from './world' -// Returns true if the block is instanceable (full cube, no rotations, etc.) export const isBlockInstanceable = (world: World, block: Block): boolean => { - // Use dynamic instanceable blocks data if available const instancedBlocks = world?.instancedBlocks - return instancedBlocks?.includes(block.name) ?? false + if (!instancedBlocks) return false + + return instancedBlocks[block.stateId] !== undefined } diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index b4aa927b..e918d853 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -657,34 +657,29 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W } if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) { // Check if this block can use instanced rendering - if (enableInstancedRendering && isBlockInstanceable(world, block)) { + if ((enableInstancedRendering && isBlockInstanceable(world, block)) || forceInstancedOnly) { // Check if block should be culled (all faces hidden by neighbors) + // TODO validate this if (shouldCullInstancedBlock(world, cursor, block)) { // Block is completely surrounded, skip rendering continue } const blockKey = block.name - const blockId = world.instancedBlockIds[block.stateId] - if (blockId !== undefined) { - if (!attr.instancedBlocks[blockKey]) { - if (blockId !== undefined) { - attr.instancedBlocks[blockKey] = { - blockId, - blockName: block.name, - stateId: block.stateId, - positions: [] - } - } + if (!attr.instancedBlocks[blockKey]) { + attr.instancedBlocks[blockKey] = { + stateId: block.stateId, + blockName: block.name, + positions: [] } - attr.instancedBlocks[blockKey].positions.push({ - x: cursor.x, - y: cursor.y, - z: cursor.z - }) - attr.blocksCount++ - continue // Skip regular geometry generation for instanceable blocks } + attr.instancedBlocks[blockKey].positions.push({ + x: cursor.x, + y: cursor.y, + z: cursor.z + }) + attr.blocksCount++ + continue // Skip regular geometry generation for instanceable blocks } // Skip buffer geometry generation if force instanced only mode is enabled diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 32b2e703..11ca4ec2 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -29,9 +29,8 @@ export type CustomBlockModels = { export type MesherConfig = typeof defaultMesherConfig export type InstancedBlockEntry = { - blockId: number // Unique ID for this block type - blockName: string stateId: number + blockName: string positions: Array<{ x: number, y: number, z: number }> } diff --git a/renderer/viewer/lib/mesher/world.ts b/renderer/viewer/lib/mesher/world.ts index bcff11b7..e02a502a 100644 --- a/renderer/viewer/lib/mesher/world.ts +++ b/renderer/viewer/lib/mesher/world.ts @@ -42,8 +42,8 @@ export class World { customBlockModels = new Map() // chunkKey -> blockModels sentBlockStateModels = new Set() blockStateModelInfo = new Map() - instancedBlocks: Record = {} - instancedBlockIds = {} as Record + instancedBlocks: Record = {} + instancedBlockIds = {} as Record constructor (version: string) { this.Chunk = Chunks(version) as any diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index 7c5ae7d8..29a7ff2b 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -21,37 +21,37 @@ function parseRgbColor (rgbString: string): number { } export interface InstancedBlockData { - blockId: number + stateId: number positions: Vec3[] blockName: string - stateId: number } export interface InstancedSectionData { sectionKey: string - instancedBlocks: Map + instancedBlocks: Map shouldUseInstancedOnly: boolean } export interface InstancedBlockModelData { stateId: number - textures: number[] + // textures: number[] rotation: number[] transparent?: boolean emitLight?: number filterLight?: number - textureInfos?: Array<{ u: number, v: number, su: number, sv: number }> // Store texture info for each face + textureInfos?: Array<{ u: number, v: number, su: number, sv: number }> } export interface InstancedBlocksConfig { instanceableBlocks: Set - blocksDataModel: Record - stateIdToModelIdMap: Record - blockNameToIdMap: Record + blocksDataModel: Record + blockNameToStateIdMap: Record interestedTextureTiles: Set } export class InstancedRenderer { + isPreflat: boolean + USE_APP_GEOMETRY = true private readonly instancedMeshes = new Map() private readonly sceneUsedMeshes = new Map() @@ -59,12 +59,12 @@ export class InstancedRenderer { private readonly sectionInstances = new Map>() private readonly cubeGeometry: THREE.BoxGeometry private readonly tempMatrix = new THREE.Matrix4() - private readonly blockIdToName = new Map() + private readonly stateIdToName = new Map() // Dynamic instance management - private readonly initialInstancesPerBlock = 2000 // Increased initial size to reduce early resizing - private readonly maxInstancesPerBlock = 100_000 // Cap per block type - private readonly maxTotalInstances = 10_000_000 // Total instance budget + private readonly initialInstancesPerBlock = 2000 + private readonly maxInstancesPerBlock = 100_000 + private readonly maxTotalInstances = 10_000_000 private currentTotalInstances = 0 private readonly growthFactor = 1.5 // How much to grow when needed @@ -74,26 +74,25 @@ export class InstancedRenderer { // Memory tracking private totalAllocatedInstances = 0 - // New properties for dynamic block detection private instancedBlocksConfig: InstancedBlocksConfig | null = null private sharedMaterial: THREE.MeshLambertMaterial | null = null constructor (private readonly worldRenderer: WorldRendererThree) { this.cubeGeometry = this.createCubeGeometry() - // Initialize world origin at camera position + this.isPreflat = versionToNumber(this.worldRenderer.version) < versionToNumber('1.13') } - private getBlockId (blockName: string): number { + private getStateId (blockName: string): number { if (!this.instancedBlocksConfig) { throw new Error('Instanced blocks config not prepared') } - const blockId = this.instancedBlocksConfig.blockNameToIdMap[blockName] - if (blockId === undefined) { - throw new Error(`Block ${blockName} not found in blockNameToIdMap`) + const stateId = this.instancedBlocksConfig.blockNameToStateIdMap[blockName] + if (stateId === undefined) { + throw new Error(`Block ${blockName} not found in blockNameToStateIdMap`) } - return blockId + return stateId } // Add getter/setter for visibility @@ -117,20 +116,20 @@ export class InstancedRenderer { debugResizeMesh () { // Debug helper to test resize operation const blockName = 'grass_block' - const blockId = this.getBlockId(blockName) - const mesh = this.instancedMeshes.get(blockId) - this.resizeInstancedMesh(blockId, mesh!.instanceMatrix.count * this.growthFactor) + const stateId = this.getStateId(blockName) + const mesh = this.instancedMeshes.get(stateId) + this.resizeInstancedMesh(stateId, mesh!.instanceMatrix.count * this.growthFactor) } - private resizeInstancedMesh (blockId: number, newSize: number): boolean { - const mesh = this.instancedMeshes.get(blockId) + private resizeInstancedMesh (stateId: number, newSize: number): boolean { + const mesh = this.instancedMeshes.get(stateId) if (!mesh) return false - const blockName = this.blockIdToName.get(blockId) || 'unknown' + const blockName = this.stateIdToName.get(stateId) || 'unknown' const oldSize = mesh.instanceMatrix.count - const actualInstanceCount = this.blockCounts.get(blockId) || 0 + const actualInstanceCount = this.blockCounts.get(stateId) || 0 - console.log(`Growing instances for ${blockName}: ${oldSize} -> ${newSize} (${((newSize / oldSize - 1) * 100).toFixed(1)}% increase)`) + // console.log(`Growing instances for ${blockName}: ${oldSize} -> ${newSize} (${((newSize / oldSize - 1) * 100).toFixed(1)}% increase)`) const { geometry } = mesh const { material } = mesh @@ -158,7 +157,7 @@ export class InstancedRenderer { this.totalAllocatedInstances += (newSize - oldSize) this.worldRenderer.scene.add(newMesh) - this.instancedMeshes.set(blockId, newMesh) + this.instancedMeshes.set(stateId, newMesh) this.worldRenderer.scene.remove(mesh) // Clean up old mesh @@ -175,12 +174,12 @@ export class InstancedRenderer { return true } - private canAddMoreInstances (blockId: number, count: number): boolean { - const currentForBlock = this.blockCounts.get(blockId) || 0 - const mesh = this.instancedMeshes.get(blockId) + private canAddMoreInstances (stateId: number, count: number): boolean { + const currentForBlock = this.blockCounts.get(stateId) || 0 + const mesh = this.instancedMeshes.get(stateId) if (!mesh) return false - const blockName = this.blockIdToName.get(blockId) || 'unknown' + const blockName = this.stateIdToName.get(stateId) || 'unknown' // If we would exceed current capacity, try to grow if (currentForBlock + count > mesh.instanceMatrix.count) { @@ -204,7 +203,7 @@ export class InstancedRenderer { } // Try to grow - if (!this.resizeInstancedMesh(blockId, newSize)) { + if (!this.resizeInstancedMesh(stateId, newSize)) { console.warn(`Failed to grow instances for ${blockName}`) return false } @@ -219,7 +218,80 @@ export class InstancedRenderer { return true } - prepareInstancedBlocksData (): InstancedBlocksConfig { + prepareInstancedBlock (stateId: number, name: string, props: Record, mcBlockData?: IndexedBlock, defaultState = false) { + const config = this.instancedBlocksConfig! + + const possibleIssues = [] as string[] + const { currentResources } = this.worldRenderer.resourcesManager + if (!currentResources?.worldBlockProvider) return + + const models = currentResources.worldBlockProvider.getAllResolvedModels0_1({ + name, + properties: props + }, this.isPreflat, possibleIssues, [], [], true) + + // skipping composite blocks + if (models.length !== 1 || !models[0]![0].elements) { + return + } + const elements = models[0]![0]?.elements + if (!elements || (elements.length !== 1 && name !== 'grass_block')) { + return + } + const elem = elements[0] + if (elem.from[0] !== 0 || elem.from[1] !== 0 || elem.from[2] !== 0 || elem.to[0] !== 16 || elem.to[1] !== 16 || elem.to[2] !== 16) { + // not full block + return + } + + const facesMapping = [ + ['front', 'south'], + ['bottom', 'down'], + ['top', 'up'], + ['right', 'east'], + ['left', 'west'], + ['back', 'north'], + ] + + const blockData: InstancedBlockModelData = { + stateId, + rotation: [0, 0, 0, 0, 0, 0], + textureInfos: Array.from({ length: 6 }).fill(null).map(() => ({ u: 0, v: 0, su: 0, sv: 0 })) + } + + for (const [face, { texture, cullface, rotation = 0 }] of Object.entries(elem.faces)) { + const faceIndex = facesMapping.findIndex(x => x.includes(face)) + if (faceIndex === -1) { + throw new Error(`Unknown face ${face}`) + } + + blockData.rotation[faceIndex] = rotation / 90 + if (Math.floor(blockData.rotation[faceIndex]) !== blockData.rotation[faceIndex]) { + throw new Error(`Invalid rotation ${rotation} ${name}`) + } + config.interestedTextureTiles.add(texture.debugName) + + // Store texture info for this face + blockData.textureInfos![faceIndex] = { + u: texture.u, + v: texture.v, + su: texture.su, + sv: texture.sv + } + } + + config.blocksDataModel[stateId] = blockData + config.instanceableBlocks.add(name) + config.blockNameToStateIdMap[name] = stateId + + if (mcBlockData) { + blockData.transparent = mcBlockData.transparent + blockData.emitLight = mcBlockData.emitLight + blockData.filterLight = mcBlockData.filterLight + } + } + + prepareInstancedBlocksData () { if (this.sharedMaterial) { this.sharedMaterial.dispose() this.sharedMaterial = null @@ -252,93 +324,17 @@ export class InstancedRenderer { 'purpur_slab': 'purpur_block', } : {} - const isPreflat = versionToNumber(this.worldRenderer.version) < versionToNumber('1.13') const PBlockOriginal = PrismarineBlock(this.worldRenderer.version) - const instanceableBlocks = new Set() - const blocksDataModel = {} as Record - const interestedTextureTiles = new Set() - const stateIdToModelIdMap = {} as Record - const blockNameToIdMap = {} as Record - let nextModelId = 0 - - const addBlockModel = (state: number, name: string, props: Record, mcBlockData?: IndexedBlock, defaultState = false) => { - const possibleIssues = [] as string[] - const { currentResources } = this.worldRenderer.resourcesManager - if (!currentResources?.worldBlockProvider) return - - const models = currentResources.worldBlockProvider.getAllResolvedModels0_1({ - name, - properties: props - }, isPreflat, possibleIssues, [], [], true) - - // skipping composite blocks - if (models.length !== 1 || !models[0]![0].elements) { - return - } - const elements = models[0]![0]?.elements - if (!elements || (elements.length !== 1 && name !== 'grass_block')) { - return - } - const elem = elements[0] - if (elem.from[0] !== 0 || elem.from[1] !== 0 || elem.from[2] !== 0 || elem.to[0] !== 16 || elem.to[1] !== 16 || elem.to[2] !== 16) { - // not full block - return - } - - const facesMapping = [ - ['front', 'south'], - ['bottom', 'down'], - ['top', 'up'], - ['right', 'east'], - ['left', 'west'], - ['back', 'north'], - ] - - const blockData: InstancedBlockModelData = { - stateId: state, - textures: [0, 0, 0, 0, 0, 0], - rotation: [0, 0, 0, 0, 0, 0], - textureInfos: Array.from({ length: 6 }).fill(null).map(() => ({ u: 0, v: 0, su: 0, sv: 0 })) - } - - const blockId = nextModelId++ - for (const [face, { texture, cullface, rotation = 0 }] of Object.entries(elem.faces)) { - const faceIndex = facesMapping.findIndex(x => x.includes(face)) - if (faceIndex === -1) { - throw new Error(`Unknown face ${face}`) - } - - blockData.textures[faceIndex] = texture.tileIndex - blockData.rotation[faceIndex] = rotation / 90 - if (Math.floor(blockData.rotation[faceIndex]) !== blockData.rotation[faceIndex]) { - throw new Error(`Invalid rotation ${rotation} ${name}`) - } - interestedTextureTiles.add(texture.debugName) - - // Store texture info for this face - blockData.textureInfos![faceIndex] = { - u: texture.u, - v: texture.v, - su: texture.su, - sv: texture.sv - } - } - - stateIdToModelIdMap[state] = blockId - blocksDataModel[blockId] = blockData - instanceableBlocks.add(name) - blockNameToIdMap[name] = blockId - - if (mcBlockData) { - blockData.transparent = mcBlockData.transparent - blockData.emitLight = mcBlockData.emitLight - blockData.filterLight = mcBlockData.filterLight - } + this.instancedBlocksConfig = { + instanceableBlocks: new Set(), + blocksDataModel: {} as Record, + blockNameToStateIdMap: {} as Record, + interestedTextureTiles: new Set(), } // Add unknown block model - addBlockModel(-1, 'unknown', {}) + this.prepareInstancedBlock(-1, 'unknown', {}) // Handle texture overrides for special blocks const textureOverrideFullBlocks = { @@ -347,17 +343,18 @@ export class InstancedRenderer { } // Process all blocks to find instanceable ones - for (const b of (globalThis as any).loadedData.blocksArray) { - for (let state = b.minStateId; state <= b.maxStateId; state++) { + for (const b of loadedData.blocksArray) { + for (let stateId = b.minStateId; stateId <= b.maxStateId; stateId++) { + const config = this.instancedBlocksConfig + const mapping = debugBlocksMap[b.name] - const block = PBlockOriginal.fromStateId(mapping && (globalThis as any).loadedData.blocksByName[mapping] ? (globalThis as any).loadedData.blocksByName[mapping].defaultState : state, 0) - if (isPreflat) { + const block = PBlockOriginal.fromStateId(mapping && loadedData.blocksByName[mapping] ? loadedData.blocksByName[mapping].defaultState : stateId, 0) + if (this.isPreflat) { getPreflatBlock(block) } const textureOverride = textureOverrideFullBlocks[block.name] as string | undefined if (textureOverride) { - const blockId = nextModelId++ const { currentResources } = this.worldRenderer.resourcesManager if (!currentResources?.worldBlockProvider) continue const texture = currentResources.worldBlockProvider.getTextureInfo(textureOverride) @@ -366,10 +363,8 @@ export class InstancedRenderer { continue } const texIndex = texture.tileIndex - stateIdToModelIdMap[state] = blockId - const blockData: InstancedBlockModelData = { - stateId: state, - textures: [texIndex, texIndex, texIndex, texIndex, texIndex, texIndex], + config.blocksDataModel[stateId] = { + stateId, rotation: [0, 0, 0, 0, 0, 0], filterLight: b.filterLight, textureInfos: Array.from({ length: 6 }).fill(null).map(() => ({ @@ -379,10 +374,9 @@ export class InstancedRenderer { sv: texture.sv })) } - blocksDataModel[blockId] = blockData - instanceableBlocks.add(block.name) - interestedTextureTiles.add(textureOverride) - blockNameToIdMap[block.name] = blockId + config.instanceableBlocks.add(block.name) + config.interestedTextureTiles.add(textureOverride) + config.blockNameToStateIdMap[block.name] = stateId continue } @@ -393,17 +387,9 @@ export class InstancedRenderer { continue } - addBlockModel(state, block.name, block.getProperties(), b, state === b.defaultState) + this.prepareInstancedBlock(stateId, block.name, block.getProperties(), b, stateId === b.defaultState) } } - - return { - instanceableBlocks, - blocksDataModel, - stateIdToModelIdMap, - blockNameToIdMap, - interestedTextureTiles - } } private createBlockMaterial (blockName: string): THREE.Material { @@ -429,33 +415,40 @@ export class InstancedRenderer { // Create InstancedMesh for each instanceable block type for (const blockName of this.instancedBlocksConfig.instanceableBlocks) { - const blockId = this.getBlockId(blockName) - const { stateId } = this.instancedBlocksConfig.blocksDataModel[blockId] - if (this.instancedMeshes.has(blockId)) continue // Skip if already exists + const stateId = this.getStateId(blockName) + this.initializeInstancedMesh(stateId, blockName) + } + } - const blockModelData = this.instancedBlocksConfig.blocksDataModel[blockId] - const initialCount = this.getInitialInstanceCount(blockName) + initializeInstancedMesh (stateId: number, blockName: string) { + if (this.instancedMeshes.has(stateId)) return // Skip if already exists - const geometry = blockModelData ? this.createCustomGeometry(stateId, blockModelData) : this.cubeGeometry - const material = this.createBlockMaterial(blockName) + if (!this.instancedBlocksConfig!.blocksDataModel) { + this.prepareInstancedBlock(stateId, blockName, {}) + } - const mesh = new THREE.InstancedMesh( - geometry, - material, - initialCount - ) - mesh.name = `instanced_${blockName}` - mesh.frustumCulled = false - mesh.count = 0 - mesh.visible = this._instancedMeshesVisible // Set initial visibility + const blockModelData = this.instancedBlocksConfig!.blocksDataModel[stateId] + const initialCount = this.getInitialInstanceCount(blockName) - this.instancedMeshes.set(blockId, mesh) - this.worldRenderer.scene.add(mesh) - this.totalAllocatedInstances += initialCount + const geometry = blockModelData ? this.createCustomGeometry(stateId, blockModelData) : this.cubeGeometry + const material = this.createBlockMaterial(blockName) - if (!blockModelData) { - console.warn(`No block model data found for block ${blockName}`) - } + const mesh = new THREE.InstancedMesh( + geometry, + material, + initialCount + ) + mesh.name = `instanced_${blockName}` + mesh.frustumCulled = false + mesh.count = 0 + mesh.visible = this._instancedMeshesVisible // Set initial visibility + + this.instancedMeshes.set(stateId, mesh) + this.worldRenderer.scene.add(mesh) + this.totalAllocatedInstances += initialCount + + if (!blockModelData) { + console.warn(`No block model data found for block ${blockName}`) } } @@ -482,7 +475,9 @@ export class InstancedRenderer { private createCustomGeometry (stateId: number, blockModelData: InstancedBlockModelData): THREE.BufferGeometry { if (this.USE_APP_GEOMETRY) { - const itemMesh = this.worldRenderer.entities.getItemMesh({ + const itemMesh = this.worldRenderer.entities.getItemMesh(stateId === -1 ? { + name: 'unknown' + } : { blockState: stateId }, {}) @@ -596,33 +591,36 @@ export class InstancedRenderer { const sectionMap = this.sectionInstances.get(sectionKey)! // Remove old instances for blocks that are being updated - const previousBlockIds = [...sectionMap.keys()] - for (const blockId of previousBlockIds) { - const instanceIndices = sectionMap.get(blockId) + const previousStateIds = [...sectionMap.keys()] + // TODO stress test updates, maybe need more effective way to remove old instances + for (const stateId of previousStateIds) { + const instanceIndices = sectionMap.get(stateId) if (instanceIndices) { - this.removeInstancesFromBlock(blockId, instanceIndices) - sectionMap.delete(blockId) + this.removeInstancesFromBlock(stateId, instanceIndices) + sectionMap.delete(stateId) } } // Keep track of blocks that were updated this frame for (const [blockName, blockData] of Object.entries(instancedBlocks)) { - if (!this.isBlockInstanceable(blockName)) continue + const { stateId } = blockData + this.stateIdToName.set(stateId, blockName) - const { blockId, stateId } = blockData - this.blockIdToName.set(blockId, blockName) + if (this.USE_APP_GEOMETRY) { // only dynamically initialize meshes for app geometry + this.initializeInstancedMesh(stateId, blockName) + } const instanceIndices: number[] = [] - const currentCount = this.blockCounts.get(blockId) || 0 + const currentCount = this.blockCounts.get(stateId) || 0 // Check if we can add all positions at once const neededInstances = blockData.positions.length - if (!this.canAddMoreInstances(blockId, neededInstances)) { + if (!this.canAddMoreInstances(stateId, neededInstances)) { console.warn(`Cannot add ${neededInstances} instances for block ${blockName} (current: ${currentCount}, max: ${this.maxInstancesPerBlock})`) continue } - const mesh = this.instancedMeshes.get(blockId)! + const mesh = this.instancedMeshes.get(stateId)! // Add new instances for this section for (const pos of blockData.positions) { @@ -636,9 +634,9 @@ export class InstancedRenderer { // Update tracking if (instanceIndices.length > 0) { - sectionMap.set(blockId, instanceIndices) + sectionMap.set(stateId, instanceIndices) const newCount = currentCount + instanceIndices.length - this.blockCounts.set(blockId, newCount) + this.blockCounts.set(stateId, newCount) this.currentTotalInstances += instanceIndices.length mesh.count = newCount // Ensure mesh.count matches our tracking mesh.instanceMatrix.needsUpdate = true @@ -656,12 +654,12 @@ export class InstancedRenderer { if (!sectionMap) return // Section not tracked // Remove instances for each block type in this section - for (const [blockId, instanceIndices] of sectionMap) { - this.removeInstancesFromBlock(blockId, instanceIndices) + for (const [stateId, instanceIndices] of sectionMap) { + this.removeInstancesFromBlock(stateId, instanceIndices) // Remove from sceneUsedMeshes if no instances left - const blockName = this.blockIdToName.get(blockId) - if (blockName && (this.blockCounts.get(blockId) || 0) === 0) { + const blockName = this.stateIdToName.get(stateId) + if (blockName && (this.blockCounts.get(stateId) || 0) === 0) { this.sceneUsedMeshes.delete(blockName) } } @@ -670,11 +668,11 @@ export class InstancedRenderer { this.sectionInstances.delete(sectionKey) } - private removeInstancesFromBlock (blockId: number, indicesToRemove: number[]) { - const mesh = this.instancedMeshes.get(blockId) + private removeInstancesFromBlock (stateId: number, indicesToRemove: number[]) { + const mesh = this.instancedMeshes.get(stateId) if (!mesh || indicesToRemove.length === 0) return - const currentCount = this.blockCounts.get(blockId) || 0 + const currentCount = this.blockCounts.get(stateId) || 0 const removeSet = new Set(indicesToRemove) // Update total instance count @@ -699,29 +697,29 @@ export class InstancedRenderer { // Update count const newCount = writeIndex - this.blockCounts.set(blockId, newCount) + this.blockCounts.set(stateId, newCount) mesh.count = newCount mesh.instanceMatrix.needsUpdate = true // Update all section tracking to reflect new indices for (const [sectionKey, sectionMap] of this.sectionInstances) { - const sectionIndices = sectionMap.get(blockId) + const sectionIndices = sectionMap.get(stateId) if (sectionIndices) { const updatedIndices = sectionIndices .map(index => indexMapping.get(index)) .filter(index => index !== undefined) if (updatedIndices.length > 0) { - sectionMap.set(blockId, updatedIndices) + sectionMap.set(stateId, updatedIndices) } else { - sectionMap.delete(blockId) + sectionMap.delete(stateId) } } } // Update sceneUsedMeshes if no instances left if (newCount === 0) { - const blockName = this.blockIdToName.get(blockId) + const blockName = this.stateIdToName.get(stateId) if (blockName) { this.sceneUsedMeshes.delete(blockName) } @@ -736,12 +734,12 @@ export class InstancedRenderer { // Reset total instance count since we're clearing everything this.currentTotalInstances = 0 - for (const [blockId, mesh] of this.instancedMeshes) { + for (const [stateId, mesh] of this.instancedMeshes) { if (mesh.material instanceof THREE.Material && mesh.material.name.startsWith('instanced_color_')) { mesh.material.dispose() } mesh.geometry.dispose() - this.instancedMeshes.delete(blockId) + this.instancedMeshes.delete(stateId) this.worldRenderer.scene.remove(mesh) } @@ -751,7 +749,7 @@ export class InstancedRenderer { destroy () { // Clean up resources - for (const [blockId, mesh] of this.instancedMeshes) { + for (const [stateId, mesh] of this.instancedMeshes) { this.worldRenderer.scene.remove(mesh) mesh.geometry.dispose() if (mesh.material instanceof THREE.Material) { @@ -761,7 +759,7 @@ export class InstancedRenderer { this.instancedMeshes.clear() this.blockCounts.clear() this.sectionInstances.clear() - this.blockIdToName.clear() + this.stateIdToName.clear() this.sceneUsedMeshes.clear() this.cubeGeometry.dispose() } @@ -772,7 +770,7 @@ export class InstancedRenderer { let activeBlockTypes = 0 let totalWastedMemory = 0 - for (const [blockId, mesh] of this.instancedMeshes) { + for (const [stateId, mesh] of this.instancedMeshes) { const allocated = mesh.instanceMatrix.count const used = mesh.count totalWastedMemory += (allocated - used) * 64 // 64 bytes per instance (approximate) @@ -809,10 +807,9 @@ export class InstancedRenderer { // New method to prepare and initialize everything prepareAndInitialize () { console.log('Preparing instanced blocks data...') - this.instancedBlocksConfig = this.prepareInstancedBlocksData() - console.log(`Found ${this.instancedBlocksConfig.instanceableBlocks.size} instanceable blocks:`, - [...this.instancedBlocksConfig.instanceableBlocks].slice(0, 10).join(', '), - this.instancedBlocksConfig.instanceableBlocks.size > 10 ? '...' : '') + this.prepareInstancedBlocksData() + const config = this.instancedBlocksConfig! + console.log(`Found ${config.instanceableBlocks.size} instanceable blocks`) this.disposeOldMeshes() this.initializeInstancedMeshes() From 5e30a4736edc379be7785e7d6cdb7dc27e3d27d4 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 09:14:04 +0300 Subject: [PATCH 16/86] rm cache --- renderer/viewer/lib/mesher/models.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index e918d853..d902e776 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -13,9 +13,6 @@ let blockProvider: WorldBlockProvider const tints: any = {} let needTiles = false -// Cache for texture info to avoid repeated calculations -const textureInfoCache = new Map() - let tintsData try { tintsData = require('esbuild-data').tints From b2257d8ae48628f4159f92be6b14e8ef51902f17 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 16 Jul 2025 09:16:06 +0300 Subject: [PATCH 17/86] rm unused code --- renderer/viewer/three/worldrendererThree.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 13eba9b1..245b03e5 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -152,16 +152,6 @@ export class WorldRendererThree extends WorldRendererCommon { } } - getInstancedBlocksData () { - const config = this.instancedRenderer?.getInstancedBlocksConfig() - if (!config) return undefined - - return { - instanceableBlocks: config.instanceableBlocks, - allBlocksStateIdToModelIdMap: config.stateIdToModelIdMap - } - } - updatePlayerEntity (e: any) { this.entities.handlePlayerEntity(e) } From 1c8799242a11d9449b219299718cd7b522860a21 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 19 Jul 2025 18:10:09 +0300 Subject: [PATCH 18/86] some important fixes --- renderer/viewer/lib/mesher/instancingUtils.ts | 3 +- renderer/viewer/lib/mesher/mesher.ts | 53 ++++++++++++++++- renderer/viewer/lib/mesher/world.ts | 2 +- renderer/viewer/lib/worldrendererCommon.ts | 5 +- renderer/viewer/three/instancedRenderer.ts | 58 ++++++++++--------- renderer/viewer/three/worldrendererThree.ts | 53 ++++++++++++++--- src/index.ts | 4 ++ 7 files changed, 138 insertions(+), 40 deletions(-) diff --git a/renderer/viewer/lib/mesher/instancingUtils.ts b/renderer/viewer/lib/mesher/instancingUtils.ts index ff4bdd10..7e5cc2a1 100644 --- a/renderer/viewer/lib/mesher/instancingUtils.ts +++ b/renderer/viewer/lib/mesher/instancingUtils.ts @@ -3,6 +3,5 @@ import { WorldBlock as Block, World } from './world' export const isBlockInstanceable = (world: World, block: Block): boolean => { const instancedBlocks = world?.instancedBlocks if (!instancedBlocks) return false - - return instancedBlocks[block.stateId] !== undefined + return instancedBlocks.includes(block.stateId) } diff --git a/renderer/viewer/lib/mesher/mesher.ts b/renderer/viewer/lib/mesher/mesher.ts index daf0b8ab..abc4d44d 100644 --- a/renderer/viewer/lib/mesher/mesher.ts +++ b/renderer/viewer/lib/mesher/mesher.ts @@ -191,6 +191,18 @@ self.onmessage = ({ data }) => { handleMessage(data) } +// Debug flag to spam last geometry output +globalThis.DEBUG_GEOMETRY_SPAM = false // set to true to enable geometry spam for performance testing +globalThis.lastGeometryKey = null + +// Track last 50 unique geometry objects with their respective keys for aggressive debugging +interface GeometryEntry { + key: string + geometry: any +} +const lastGeometryEntries: GeometryEntry[] = [] +const MAX_GEOMETRY_ENTRIES = 50 + setInterval(() => { if (world === null || !allDataReady) return @@ -205,10 +217,24 @@ setInterval(() => { if (chunk?.getSection(new Vec3(x, y, z))) { const start = performance.now() const geometry = getSectionGeometry(x, y, z, world, instancingMode) - const transferable = [geometry.positions?.buffer, geometry.normals?.buffer, geometry.colors?.buffer, geometry.uvs?.buffer].filter(Boolean) - //@ts-expect-error - postMessage({ type: 'geometry', key, geometry, workerIndex }, transferable) + const transferable = [geometry.positions?.buffer, geometry.normals?.buffer, geometry.colors?.buffer, geometry.uvs?.buffer].filter(Boolean) as any + postMessage({ type: 'geometry', key, geometry, workerIndex }/* , transferable */) processTime = performance.now() - start + // Store last geometry for debug spam + globalThis.lastGeometryKey = key + + // Track unique geometry entries for aggressive debugging + const existingIndex = lastGeometryEntries.findIndex(entry => entry.key === key) + if (existingIndex >= 0) { + // Update existing entry with new geometry + lastGeometryEntries[existingIndex].geometry = geometry + } else { + // Add new entry + lastGeometryEntries.push({ key, geometry }) + if (lastGeometryEntries.length > MAX_GEOMETRY_ENTRIES) { + lastGeometryEntries.shift() // Remove oldest + } + } } else { // console.info('[mesher] Missing section', x, y, z) } @@ -238,3 +264,24 @@ setInterval(() => { // const time = performance.now() - start // console.log(`Processed ${sections.length} sections in ${time} ms (${time / sections.length} ms/section)`) }, 50) + +// Debug spam: repeatedly send last geometry output every 100ms +setInterval(() => { + if (globalThis.DEBUG_GEOMETRY_SPAM) { + // Send the last geometry + + // Aggressive debugging: send all tracked geometry entries with their respective geometries + // console.log(`[DEBUG] Sending ${lastGeometryEntries.length} unique geometry entries:`, lastGeometryEntries.map(e => e.key)) + + // Send each unique geometry entry with its respective geometry for maximum stress testing + for (const entry of lastGeometryEntries) { + postMessage({ + type: 'geometry', + key: entry.key, + geometry: entry.geometry, + workerIndex, + debug: true // Mark as debug message + }) + } + } +}, 20) diff --git a/renderer/viewer/lib/mesher/world.ts b/renderer/viewer/lib/mesher/world.ts index e02a502a..59bc5a34 100644 --- a/renderer/viewer/lib/mesher/world.ts +++ b/renderer/viewer/lib/mesher/world.ts @@ -42,7 +42,7 @@ export class World { customBlockModels = new Map() // chunkKey -> blockModels sentBlockStateModels = new Set() blockStateModelInfo = new Map() - instancedBlocks: Record = {} + instancedBlocks: number[] = [] instancedBlockIds = {} as Record constructor (version: string) { diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index 85d677fd..a8153ddd 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -60,6 +60,9 @@ export const defaultWorldRendererConfig = { // New instancing options useInstancedRendering: false, forceInstancedOnly: false, + dynamicInstancing: false, + dynamicInstancingModeDistance: 1, // chunks beyond this distance use instancing only + dynamicColorModeDistance: 1, // chunks beyond this distance use color mode only instancedOnlyDistance: 6, // chunks beyond this distance use instancing only enableSingleColorMode: false, // ultra-performance mode with solid colors } @@ -161,7 +164,7 @@ export abstract class WorldRendererCommon abstract changeBackgroundColor (color: [number, number, number]): void // Optional method for getting instanced blocks data (implemented by Three.js renderer) - getInstancedBlocksData? (): { instanceableBlocks: Set, allBlocksStateIdToModelIdMap: Record } | undefined + getInstancedBlocksData? (): { instanceableBlocks?: Set, allBlocksStateIdToModelIdMap?: Record } | undefined worldRendererConfig: WorldRendererConfig playerStateReactive: PlayerStateReactive diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index 29a7ff2b..00359fe4 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -4,7 +4,7 @@ import { versionToNumber } from 'flying-squid/dist/utils' import PrismarineBlock from 'prismarine-block' import { IndexedBlock } from 'minecraft-data' import moreBlockData from '../lib/moreBlockDataGenerated.json' -import { MesherGeometryOutput } from '../lib/mesher/shared' +import { InstancingMode, MesherGeometryOutput } from '../lib/mesher/shared' import { getPreflatBlock } from './getPreflatBlock' import { WorldRendererThree } from './worldrendererThree' @@ -43,7 +43,7 @@ export interface InstancedBlockModelData { } export interface InstancedBlocksConfig { - instanceableBlocks: Set + instanceableBlocks: Set blocksDataModel: Record blockNameToStateIdMap: Record interestedTextureTiles: Set @@ -75,7 +75,8 @@ export class InstancedRenderer { private totalAllocatedInstances = 0 private instancedBlocksConfig: InstancedBlocksConfig | null = null - private sharedMaterial: THREE.MeshLambertMaterial | null = null + private sharedSolidMaterial: THREE.MeshLambertMaterial | null = null + private sharedTransparentMaterial: THREE.MeshLambertMaterial | null = null constructor (private readonly worldRenderer: WorldRendererThree) { this.cubeGeometry = this.createCubeGeometry() @@ -281,7 +282,7 @@ export class InstancedRenderer { } config.blocksDataModel[stateId] = blockData - config.instanceableBlocks.add(name) + config.instanceableBlocks.add(stateId) config.blockNameToStateIdMap[name] = stateId if (mcBlockData) { @@ -292,15 +293,22 @@ export class InstancedRenderer { } prepareInstancedBlocksData () { - if (this.sharedMaterial) { - this.sharedMaterial.dispose() - this.sharedMaterial = null + if (this.sharedSolidMaterial) { + this.sharedSolidMaterial.dispose() + this.sharedSolidMaterial = null } - this.sharedMaterial = new THREE.MeshLambertMaterial({ + this.sharedSolidMaterial = new THREE.MeshLambertMaterial({ transparent: true, + // depthWrite: true, alphaTest: 0.1 }) - this.sharedMaterial.map = this.worldRenderer.material.map + this.sharedSolidMaterial.map = this.worldRenderer.material.map + this.sharedTransparentMaterial = new THREE.MeshLambertMaterial({ + transparent: true, + // depthWrite: false, + alphaTest: 0.1 + }) + this.sharedTransparentMaterial.map = this.worldRenderer.material.map const { forceInstancedOnly } = this.worldRenderer.worldRendererConfig const debugBlocksMap = forceInstancedOnly ? { @@ -327,11 +335,11 @@ export class InstancedRenderer { const PBlockOriginal = PrismarineBlock(this.worldRenderer.version) this.instancedBlocksConfig = { - instanceableBlocks: new Set(), - blocksDataModel: {} as Record, - blockNameToStateIdMap: {} as Record, - interestedTextureTiles: new Set(), - } + instanceableBlocks: new Set(), + blocksDataModel: {}, + blockNameToStateIdMap: {}, + interestedTextureTiles: new Set(), + } satisfies InstancedBlocksConfig // Add unknown block model this.prepareInstancedBlock(-1, 'unknown', {}) @@ -374,7 +382,7 @@ export class InstancedRenderer { sv: texture.sv })) } - config.instanceableBlocks.add(block.name) + config.instanceableBlocks.add(block.stateId) config.interestedTextureTiles.add(textureOverride) config.blockNameToStateIdMap[block.name] = stateId continue @@ -392,7 +400,7 @@ export class InstancedRenderer { } } - private createBlockMaterial (blockName: string): THREE.Material { + private createBlockMaterial (blockName: string, isTransparent: boolean): THREE.Material { const { enableSingleColorMode } = this.worldRenderer.worldRendererConfig if (enableSingleColorMode) { @@ -402,7 +410,7 @@ export class InstancedRenderer { material.name = `instanced_color_${blockName}` return material } else { - return this.sharedMaterial! + return isTransparent ? this.sharedTransparentMaterial! : this.sharedSolidMaterial! } } @@ -414,9 +422,8 @@ export class InstancedRenderer { } // Create InstancedMesh for each instanceable block type - for (const blockName of this.instancedBlocksConfig.instanceableBlocks) { - const stateId = this.getStateId(blockName) - this.initializeInstancedMesh(stateId, blockName) + for (const stateId of this.instancedBlocksConfig.instanceableBlocks) { + this.initializeInstancedMesh(stateId, this.stateIdToName.get(stateId)!) } } @@ -428,10 +435,11 @@ export class InstancedRenderer { } const blockModelData = this.instancedBlocksConfig!.blocksDataModel[stateId] + const isTransparent = blockModelData?.transparent ?? false const initialCount = this.getInitialInstanceCount(blockName) const geometry = blockModelData ? this.createCustomGeometry(stateId, blockModelData) : this.cubeGeometry - const material = this.createBlockMaterial(blockName) + const material = this.createBlockMaterial(blockName, isTransparent) const mesh = new THREE.InstancedMesh( geometry, @@ -443,6 +451,8 @@ export class InstancedRenderer { mesh.count = 0 mesh.visible = this._instancedMeshesVisible // Set initial visibility + // mesh.renderOrder = isTransparent ? 1 : 0 + this.instancedMeshes.set(stateId, mesh) this.worldRenderer.scene.add(mesh) this.totalAllocatedInstances += initialCount @@ -583,7 +593,7 @@ export class InstancedRenderer { return 0x99_99_99 } - handleInstancedBlocksFromWorker (instancedBlocks: MesherGeometryOutput['instancedBlocks'], sectionKey: string) { + handleInstancedBlocksFromWorker (instancedBlocks: MesherGeometryOutput['instancedBlocks'], sectionKey: string, instancingMode: InstancingMode) { // Initialize section tracking if not exists if (!this.sectionInstances.has(sectionKey)) { this.sectionInstances.set(sectionKey, new Map()) @@ -726,10 +736,6 @@ export class InstancedRenderer { } } - isBlockInstanceable (blockName: string): boolean { - return this.instancedBlocksConfig?.instanceableBlocks.has(blockName) ?? false - } - disposeOldMeshes () { // Reset total instance count since we're clearing everything this.currentTotalInstances = 0 diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 245b03e5..884cc840 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -31,6 +31,7 @@ type SectionKey = string export class WorldRendererThree extends WorldRendererCommon { outputFormat = 'threeJs' as const sectionObjects: Record = {} + sectionInstancingMode: Record = {} chunkTextures = new Map() signsCache = new Map() starField: StarField @@ -209,6 +210,15 @@ export class WorldRendererThree extends WorldRendererCommon { }) } + getInstancedBlocksData () { + const config = this.instancedRenderer?.getInstancedBlocksConfig() + if (!config) return undefined + + return { + instanceableBlocks: config.instanceableBlocks, + } + } + override watchReactiveConfig () { super.watchReactiveConfig() this.onReactiveConfigUpdated('showChunkBorders', (value) => { @@ -364,6 +374,16 @@ export class WorldRendererThree extends WorldRendererCommon { const value = this.sectionObjects[key] if (!value) continue this.updatePosDataChunk(key) + + if (this.worldRendererConfig.dynamicInstancing) { + const [x, y, z] = key.split(',').map(x => +x / 16) + const pos = new Vec3(x, y, z) + const instancingMode = this.getInstancingMode(pos) + if (instancingMode !== this.sectionInstancingMode[key]) { + // update section + this.setSectionDirty(pos) + } + } } } @@ -386,12 +406,17 @@ export class WorldRendererThree extends WorldRendererCommon { const chunkCoords = data.key.split(',') const chunkKey = chunkCoords[0] + ',' + chunkCoords[2] - // Always clear old instanced blocks for this section first, then add new ones if any - this.instancedRenderer?.removeSectionInstances(data.key) + const hasInstancedBlocks = data.geometry.instancedBlocks && Object.keys(data.geometry.instancedBlocks).length > 0 + const shouldUpdateInstanced = hasInstancedBlocks || this.sectionObjects[data.key] + + if (shouldUpdateInstanced) { + // Only remove if we have something to replace it with or need to clear existing + this.instancedRenderer?.removeSectionInstances(data.key) + } // Handle instanced blocks data from worker - if (data.geometry.instancedBlocks && Object.keys(data.geometry.instancedBlocks).length > 0) { - this.instancedRenderer?.handleInstancedBlocksFromWorker(data.geometry.instancedBlocks, data.key) + if (hasInstancedBlocks) { + this.instancedRenderer?.handleInstancedBlocksFromWorker(data.geometry.instancedBlocks, data.key, this.getInstancingMode(new Vec3(chunkCoords[0], chunkCoords[1], chunkCoords[2]))) } let object: THREE.Object3D = this.sectionObjects[data.key] @@ -982,8 +1007,8 @@ export class WorldRendererThree extends WorldRendererCommon { } } - setSectionDirty (pos: Vec3, value = true) { - const { useInstancedRendering, enableSingleColorMode, forceInstancedOnly } = this.worldRendererConfig + getInstancingMode (pos: Vec3) { + const { useInstancedRendering, enableSingleColorMode, forceInstancedOnly, dynamicInstancing, dynamicInstancingModeDistance, dynamicColorModeDistance } = this.worldRendererConfig let instancingMode = InstancingMode.None if (useInstancedRendering || enableSingleColorMode) { @@ -992,10 +1017,24 @@ export class WorldRendererThree extends WorldRendererCommon { : forceInstancedOnly ? InstancingMode.BlockInstancingOnly : InstancingMode.BlockInstancing + } else if (dynamicInstancing) { + const distance = Math.abs(pos.x / 16 - this.cameraSectionPos.x) + Math.abs(pos.z / 16 - this.cameraSectionPos.z) + if (distance > dynamicColorModeDistance) { + instancingMode = InstancingMode.ColorOnly + } else if (distance > dynamicInstancingModeDistance) { + instancingMode = InstancingMode.BlockInstancingOnly + } } + return instancingMode + } + + setSectionDirty (pos: Vec3, value = true) { this.cleanChunkTextures(pos.x, pos.z) // todo don't do this! - super.setSectionDirty(pos, value, undefined, instancingMode) + super.setSectionDirty(pos, value, undefined, this.getInstancingMode(pos)) + if (value) { + this.sectionInstancingMode[pos.toString()] = this.getInstancingMode(pos) + } } static getRendererInfo (renderer: THREE.WebGLRenderer) { diff --git a/src/index.ts b/src/index.ts index 185caab6..639a8dfe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -276,6 +276,10 @@ export async function connect (connectOptions: ConnectOptions) { return } } + if (e.reason?.stack?.includes('chrome-extension://')) { + // ignore issues caused by chrome extension + return + } handleError(e.reason) }, { signal: errorAbortController.signal From 0240a752ada055d7f27732a900b153f7479c5a83 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 19 Jul 2025 18:11:01 +0300 Subject: [PATCH 19/86] box helper optim --- renderer/viewer/lib/mesher/models.ts | 36 +++++- renderer/viewer/lib/mesher/shared.ts | 3 +- renderer/viewer/three/instancedRenderer.ts | 104 ++++++++++++------ renderer/viewer/three/worldrendererThree.ts | 115 +++++++++++++------- 4 files changed, 179 insertions(+), 79 deletions(-) diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index d902e776..6a01bf2e 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -552,7 +552,27 @@ const shouldCullInstancedBlock = (world: World, cursor: Vec3, block: Block): boo return true } +// Add matrix calculation helper +function calculateInstanceMatrix (pos: { x: number, y: number, z: number }, offset = 0.5): number[] { + // Create a 4x4 matrix array (16 elements) + const matrix = Array.from({ length: 16 }).fill(0) as number[] + + // Set identity matrix + matrix[0] = 1 // m11 + matrix[5] = 1 // m22 + matrix[10] = 1 // m33 + matrix[15] = 1 // m44 + + // Set translation (position) + matrix[12] = pos.x + offset // tx + matrix[13] = pos.y + offset // ty + matrix[14] = pos.z + offset // tz + + return matrix +} + let unknownBlockModel: BlockModelPartsResolved + export function getSectionGeometry (sx: number, sy: number, sz: number, world: World, instancingMode = InstancingMode.None): MesherGeometryOutput { let delayedRender = [] as Array<() => void> @@ -667,14 +687,24 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W attr.instancedBlocks[blockKey] = { stateId: block.stateId, blockName: block.name, - positions: [] + positions: [], + matrices: [] // Add matrices array } } - attr.instancedBlocks[blockKey].positions.push({ + + const pos = { x: cursor.x, y: cursor.y, z: cursor.z - }) + } + + // Pre-calculate transformation matrix + const offset = instancingMode === InstancingMode.ColorOnly ? 0 : 0.5 + const matrix = calculateInstanceMatrix(pos, offset) + + attr.instancedBlocks[blockKey].positions.push(pos) + attr.instancedBlocks[blockKey].matrices.push(matrix) + attr.blocksCount++ continue // Skip regular geometry generation for instanceable blocks } diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 11ca4ec2..53fc78ff 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -28,10 +28,11 @@ export type CustomBlockModels = { export type MesherConfig = typeof defaultMesherConfig -export type InstancedBlockEntry = { +export interface InstancedBlockEntry { stateId: number blockName: string positions: Array<{ x: number, y: number, z: number }> + matrices: number[][] // Pre-calculated transformation matrices from worker } export type InstancingMesherData = { diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts index 00359fe4..0b461c51 100644 --- a/renderer/viewer/three/instancedRenderer.ts +++ b/renderer/viewer/three/instancedRenderer.ts @@ -61,6 +61,9 @@ export class InstancedRenderer { private readonly tempMatrix = new THREE.Matrix4() private readonly stateIdToName = new Map() + // Cache for single color materials + private readonly colorMaterials = new Map() + // Dynamic instance management private readonly initialInstancesPerBlock = 2000 private readonly maxInstancesPerBlock = 100_000 @@ -76,11 +79,16 @@ export class InstancedRenderer { private instancedBlocksConfig: InstancedBlocksConfig | null = null private sharedSolidMaterial: THREE.MeshLambertMaterial | null = null - private sharedTransparentMaterial: THREE.MeshLambertMaterial | null = null constructor (private readonly worldRenderer: WorldRendererThree) { this.cubeGeometry = this.createCubeGeometry() this.isPreflat = versionToNumber(this.worldRenderer.version) < versionToNumber('1.13') + + // Create shared solid material with no transparency + this.sharedSolidMaterial = new THREE.MeshLambertMaterial({ + transparent: false, + alphaTest: 0.1 + }) } private getStateId (blockName: string): number { @@ -170,7 +178,7 @@ export class InstancedRenderer { } // Verify instance count matches - console.log(`Finished growing ${blockName}. Actual instances: ${actualInstanceCount}, New capacity: ${newSize}, Mesh count: ${newMesh.count}`) + // console.log(`Finished growing ${blockName}. Actual instances: ${actualInstanceCount}, New capacity: ${newSize}, Mesh count: ${newMesh.count}`) return true } @@ -194,7 +202,7 @@ export class InstancedRenderer { )) ) - console.log(`Need to grow ${blockName}: current ${currentForBlock}/${currentCapacity}, need ${neededCapacity}, growing to ${newSize}`) + // console.log(`Need to grow ${blockName}: current ${currentForBlock}/${currentCapacity}, need ${neededCapacity}, growing to ${newSize}`) // Check if growth would exceed total budget const growthAmount = newSize - currentCapacity @@ -303,12 +311,12 @@ export class InstancedRenderer { alphaTest: 0.1 }) this.sharedSolidMaterial.map = this.worldRenderer.material.map - this.sharedTransparentMaterial = new THREE.MeshLambertMaterial({ - transparent: true, - // depthWrite: false, - alphaTest: 0.1 - }) - this.sharedTransparentMaterial.map = this.worldRenderer.material.map + // this.sharedTransparentMaterial = new THREE.MeshLambertMaterial({ + // transparent: true, + // // depthWrite: false, + // alphaTest: 0.1 + // }) + // this.sharedTransparentMaterial.map = this.worldRenderer.material.map const { forceInstancedOnly } = this.worldRenderer.worldRendererConfig const debugBlocksMap = forceInstancedOnly ? { @@ -400,17 +408,27 @@ export class InstancedRenderer { } } - private createBlockMaterial (blockName: string, isTransparent: boolean): THREE.Material { - const { enableSingleColorMode } = this.worldRenderer.worldRendererConfig + private getOrCreateColorMaterial (blockName: string): THREE.Material { + const color = this.getBlockColor(blockName) + const materialKey = color - if (enableSingleColorMode) { - // Ultra-performance mode: solid colors only - const color = this.getBlockColor(blockName) - const material = new THREE.MeshBasicMaterial({ color }) + let material = this.colorMaterials.get(materialKey) + if (!material) { + material = new THREE.MeshBasicMaterial({ + color, + transparent: false + }) material.name = `instanced_color_${blockName}` - return material + this.colorMaterials.set(materialKey, material) + } + return material + } + + private createBlockMaterial (blockName: string, instancingMode: InstancingMode): THREE.Material { + if (instancingMode === InstancingMode.ColorOnly) { + return this.getOrCreateColorMaterial(blockName) } else { - return isTransparent ? this.sharedTransparentMaterial! : this.sharedSolidMaterial! + return this.sharedSolidMaterial! } } @@ -423,11 +441,14 @@ export class InstancedRenderer { // Create InstancedMesh for each instanceable block type for (const stateId of this.instancedBlocksConfig.instanceableBlocks) { - this.initializeInstancedMesh(stateId, this.stateIdToName.get(stateId)!) + const blockName = this.stateIdToName.get(stateId) + if (blockName) { + this.initializeInstancedMesh(stateId, blockName, InstancingMode.ColorOnly) + } } } - initializeInstancedMesh (stateId: number, blockName: string) { + initializeInstancedMesh (stateId: number, blockName: string, instancingMode: InstancingMode) { if (this.instancedMeshes.has(stateId)) return // Skip if already exists if (!this.instancedBlocksConfig!.blocksDataModel) { @@ -439,7 +460,7 @@ export class InstancedRenderer { const initialCount = this.getInitialInstanceCount(blockName) const geometry = blockModelData ? this.createCustomGeometry(stateId, blockModelData) : this.cubeGeometry - const material = this.createBlockMaterial(blockName, isTransparent) + const material = this.createBlockMaterial(blockName, instancingMode) const mesh = new THREE.InstancedMesh( geometry, @@ -454,7 +475,7 @@ export class InstancedRenderer { // mesh.renderOrder = isTransparent ? 1 : 0 this.instancedMeshes.set(stateId, mesh) - this.worldRenderer.scene.add(mesh) + // Don't add to scene until actually used this.totalAllocatedInstances += initialCount if (!blockModelData) { @@ -589,6 +610,9 @@ export class InstancedRenderer { return parseRgbColor(rgbString) } + // Debug: Log when color is not found + console.warn(`No color found for block: ${blockName}, using default gray`) + // Fallback to default gray if color not found return 0x99_99_99 } @@ -602,7 +626,6 @@ export class InstancedRenderer { // Remove old instances for blocks that are being updated const previousStateIds = [...sectionMap.keys()] - // TODO stress test updates, maybe need more effective way to remove old instances for (const stateId of previousStateIds) { const instanceIndices = sectionMap.get(stateId) if (instanceIndices) { @@ -613,18 +636,18 @@ export class InstancedRenderer { // Keep track of blocks that were updated this frame for (const [blockName, blockData] of Object.entries(instancedBlocks)) { - const { stateId } = blockData + const { stateId, positions, matrices } = blockData this.stateIdToName.set(stateId, blockName) - if (this.USE_APP_GEOMETRY) { // only dynamically initialize meshes for app geometry - this.initializeInstancedMesh(stateId, blockName) + if (this.USE_APP_GEOMETRY) { + this.initializeInstancedMesh(stateId, blockName, instancingMode) } const instanceIndices: number[] = [] const currentCount = this.blockCounts.get(stateId) || 0 // Check if we can add all positions at once - const neededInstances = blockData.positions.length + const neededInstances = positions.length if (!this.canAddMoreInstances(stateId, neededInstances)) { console.warn(`Cannot add ${neededInstances} instances for block ${blockName} (current: ${currentCount}, max: ${this.maxInstancesPerBlock})`) continue @@ -632,13 +655,10 @@ export class InstancedRenderer { const mesh = this.instancedMeshes.get(stateId)! - // Add new instances for this section - for (const pos of blockData.positions) { + // Add new instances for this section using pre-calculated matrices from worker + for (let i = 0; i < positions.length; i++) { const instanceIndex = currentCount + instanceIndices.length - const offset = this.USE_APP_GEOMETRY ? 0 : 0.5 - - this.tempMatrix.setPosition(pos.x + offset, pos.y + offset, pos.z + offset) - mesh.setMatrixAt(instanceIndex, this.tempMatrix) + mesh.setMatrixAt(instanceIndex, new THREE.Matrix4().fromArray(matrices[i])) instanceIndices.push(instanceIndex) } @@ -648,13 +668,14 @@ export class InstancedRenderer { const newCount = currentCount + instanceIndices.length this.blockCounts.set(stateId, newCount) this.currentTotalInstances += instanceIndices.length - mesh.count = newCount // Ensure mesh.count matches our tracking + mesh.count = newCount mesh.instanceMatrix.needsUpdate = true - // Only track mesh in sceneUsedMeshes if it's actually being used - if (newCount > 0) { - this.sceneUsedMeshes.set(blockName, mesh) + // Only add mesh to scene when it's first used + if (newCount === instanceIndices.length) { + this.worldRenderer.scene.add(mesh) } + this.sceneUsedMeshes.set(blockName, mesh) } } } @@ -762,6 +783,17 @@ export class InstancedRenderer { mesh.material.dispose() } } + + // Clean up materials + if (this.sharedSolidMaterial) { + this.sharedSolidMaterial.dispose() + this.sharedSolidMaterial = null + } + for (const material of this.colorMaterials.values()) { + material.dispose() + } + this.colorMaterials.clear() + this.instancedMeshes.clear() this.blockCounts.clear() this.sectionInstances.clear() diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 884cc840..ce21b1de 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -30,7 +30,7 @@ type SectionKey = string export class WorldRendererThree extends WorldRendererCommon { outputFormat = 'threeJs' as const - sectionObjects: Record = {} + sectionObjects: Record }> = {} sectionInstancingMode: Record = {} chunkTextures = new Map() signsCache = new Map() @@ -56,6 +56,7 @@ export class WorldRendererThree extends WorldRendererCommon { waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] } camera: THREE.PerspectiveCamera renderTimeAvg = 0 + chunkBoxMaterial = new THREE.MeshBasicMaterial({ color: 0x00_00_00, transparent: true, opacity: 0 }) sectionsOffsetsAnimations = {} as { [chunkKey: string]: { time: number, @@ -222,7 +223,7 @@ export class WorldRendererThree extends WorldRendererCommon { override watchReactiveConfig () { super.watchReactiveConfig() this.onReactiveConfigUpdated('showChunkBorders', (value) => { - this.updateShowChunksBorder(value) + this.updateShowChunksBorder() }) } @@ -370,16 +371,18 @@ export class WorldRendererThree extends WorldRendererCommon { cameraSectionPositionUpdate () { // eslint-disable-next-line guard-for-in - for (const key in this.sectionObjects) { - const value = this.sectionObjects[key] - if (!value) continue - this.updatePosDataChunk(key) + for (const key in this.sectionInstancingMode) { + const object = this.sectionObjects[key] + if (object) { + this.updatePosDataChunk(key) + } if (this.worldRendererConfig.dynamicInstancing) { - const [x, y, z] = key.split(',').map(x => +x / 16) + const [x, y, z] = key.split(',').map(x => +x) const pos = new Vec3(x, y, z) const instancingMode = this.getInstancingMode(pos) if (instancingMode !== this.sectionInstancingMode[key]) { + // console.log('update section', key, this.sectionInstancingMode[key], '->', instancingMode) // update section this.setSectionDirty(pos) } @@ -407,55 +410,55 @@ export class WorldRendererThree extends WorldRendererCommon { const chunkKey = chunkCoords[0] + ',' + chunkCoords[2] const hasInstancedBlocks = data.geometry.instancedBlocks && Object.keys(data.geometry.instancedBlocks).length > 0 - const shouldUpdateInstanced = hasInstancedBlocks || this.sectionObjects[data.key] - if (shouldUpdateInstanced) { - // Only remove if we have something to replace it with or need to clear existing - this.instancedRenderer?.removeSectionInstances(data.key) - } + this.instancedRenderer?.removeSectionInstances(data.key) + + // if (data.key === '48,64,32') { + // console.log('handleWorkerMessage', data.key, this.sectionObjects[data.key], this.sectionInstancingMode[data.key], Object.keys(data.geometry.instancedBlocks).length, data.geometry.positions.length) + // } // Handle instanced blocks data from worker if (hasInstancedBlocks) { this.instancedRenderer?.handleInstancedBlocksFromWorker(data.geometry.instancedBlocks, data.key, this.getInstancingMode(new Vec3(chunkCoords[0], chunkCoords[1], chunkCoords[2]))) } - let object: THREE.Object3D = this.sectionObjects[data.key] + let object = this.sectionObjects[data.key] if (object) { this.scene.remove(object) disposeObject(object) delete this.sectionObjects[data.key] } + object = this.sectionObjects[data.key] + if (!this.loadedChunks[chunkKey] || !data.geometry.positions.length || !this.active) return // if (object) { // this.debugRecomputedDeletedObjects++ // } - const geometry = new THREE.BufferGeometry() + const geometry = object?.mesh?.geometry ?? new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.BufferAttribute(data.geometry.positions, 3)) geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3)) geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3)) geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2)) geometry.index = new THREE.BufferAttribute(data.geometry.indices as Uint32Array | Uint16Array, 1) + const { sx, sy, sz } = data.geometry - const mesh = new THREE.Mesh(geometry, this.material) + // Set bounding box for the 16x16x16 section + geometry.boundingBox = new THREE.Box3(new THREE.Vector3(-8, -8, -8), new THREE.Vector3(8, 8, 8)) + geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), Math.sqrt(3 * 8 ** 2)) + + const mesh = object?.mesh ?? new THREE.Mesh(geometry, this.material) mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz) mesh.name = 'mesh' - object = new THREE.Group() - object.add(mesh) - // mesh with static dimensions: 16x16x16 - const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), new THREE.MeshBasicMaterial({ color: 0x00_00_00, transparent: true, opacity: 0 })) - staticChunkMesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz) - const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00) - boxHelper.name = 'helper' - object.add(boxHelper) - object.name = 'chunk'; + if (!object) { + object = new THREE.Group() + object.add(mesh) + } (object as any).tilesCount = data.geometry.positions.length / 3 / 4; (object as any).blocksCount = data.geometry.blocksCount - if (!this.displayOptions.inWorldRenderingConfig.showChunkBorders) { - boxHelper.visible = false - } + // should not compute it once if (Object.keys(data.geometry.signs).length) { for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(data.geometry.signs)) { @@ -477,7 +480,15 @@ export class WorldRendererThree extends WorldRendererCommon { object.add(head) } } - this.sectionObjects[data.key] = object + + if (!this.sectionObjects[data.key]) { + this.sectionObjects[data.key] = object + this.sectionObjects[data.key].mesh = mesh + this.scene.add(object) + } + + this.updateBoxHelper(data.key) + if (this.displayOptions.inWorldRenderingConfig._renderByChunks) { object.visible = false const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}` @@ -494,8 +505,6 @@ export class WorldRendererThree extends WorldRendererCommon { mesh.onAfterRender = (renderer, scene, camera, geometry, material, group) => { // mesh.matrixAutoUpdate = false } - - this.scene.add(object) } getSignTexture (position: Vec3, blockEntity, backSide = false) { @@ -923,13 +932,32 @@ export class WorldRendererThree extends WorldRendererCommon { } } - updateShowChunksBorder (value: boolean) { - for (const object of Object.values(this.sectionObjects)) { - for (const child of object.children) { - if (child.name === 'helper') { - child.visible = value - } + updateShowChunksBorder () { + for (const key of Object.keys(this.sectionObjects)) { + this.updateBoxHelper(key) + } + } + + updateBoxHelper (key: string) { + const { showChunkBorders } = this.worldRendererConfig + const section = this.sectionObjects[key] + if (!section) return + if (showChunkBorders) { + if (!section.boxHelper) { + // mesh with static dimensions: 16x16x16 + const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), this.chunkBoxMaterial) + staticChunkMesh.position.set(section.mesh!.position.x, section.mesh!.position.y, section.mesh!.position.z) + const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00) + boxHelper.name = 'helper' + // boxHelper.geometry.boundingSphere = section.mesh!.geometry.boundingSphere + section.add(boxHelper) + section.name = 'chunk' + section.boxHelper = boxHelper } + + section.boxHelper.visible = true + } else if (section.boxHelper) { + section.boxHelper.visible = false } } @@ -987,6 +1015,11 @@ export class WorldRendererThree extends WorldRendererCommon { } } + removeCurrentChunk () { + const currentChunk = this.cameraSectionPos + this.removeColumn(currentChunk.x * 16, currentChunk.z * 16) + } + removeColumn (x, z) { super.removeColumn(x, z) @@ -1018,7 +1051,10 @@ export class WorldRendererThree extends WorldRendererCommon { ? InstancingMode.BlockInstancingOnly : InstancingMode.BlockInstancing } else if (dynamicInstancing) { - const distance = Math.abs(pos.x / 16 - this.cameraSectionPos.x) + Math.abs(pos.z / 16 - this.cameraSectionPos.z) + const dx = pos.x / 16 - this.cameraSectionPos.x + const dz = pos.z / 16 - this.cameraSectionPos.z + const distance = Math.floor(Math.hypot(dx, dz)) + // console.log('distance', distance, `${pos.x},${pos.y},${pos.z}`) if (distance > dynamicColorModeDistance) { instancingMode = InstancingMode.ColorOnly } else if (distance > dynamicInstancingModeDistance) { @@ -1031,9 +1067,10 @@ export class WorldRendererThree extends WorldRendererCommon { setSectionDirty (pos: Vec3, value = true) { this.cleanChunkTextures(pos.x, pos.z) // todo don't do this! - super.setSectionDirty(pos, value, undefined, this.getInstancingMode(pos)) + const instancingMode = this.getInstancingMode(pos) + super.setSectionDirty(pos, value, undefined, instancingMode) if (value) { - this.sectionInstancingMode[pos.toString()] = this.getInstancingMode(pos) + this.sectionInstancingMode[`${pos.x},${pos.y},${pos.z}`] = instancingMode } } From 9ee28ef62f8dfb5b1d806d249cb32c9b5142dbe3 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 20 Jul 2025 08:21:15 +0300 Subject: [PATCH 20/86] working geometry pool manager! --- renderer/viewer/lib/worldDataEmitter.ts | 4 +- renderer/viewer/lib/worldrendererCommon.ts | 1 - renderer/viewer/three/chunkMeshManager.ts | 298 ++++++++++++++++++++ renderer/viewer/three/worldrendererThree.ts | 158 ++++++----- 4 files changed, 390 insertions(+), 71 deletions(-) create mode 100644 renderer/viewer/three/chunkMeshManager.ts diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index e1ac2f24..c43c9353 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -20,7 +20,7 @@ export type WorldDataEmitterEvents = { entityMoved: (data: any) => void playerEntity: (data: any) => void time: (data: number) => void - renderDistance: (viewDistance: number) => void + renderDistance: (viewDistance: number, keepChunksDistance: number) => void blockEntities: (data: Record | { blockEntities: Record }) => void markAsLoaded: (data: { x: number, z: number }) => void unloadChunk: (data: { x: number, z: number }) => void @@ -79,7 +79,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter this.allChunksFinished = true this.allLoadedIn ??= Date.now() - this.initialChunkLoadWasStartedIn! } - this.updateChunksStats() } changeHandSwingingState (isAnimationPlaying: boolean, isLeftHand: boolean): void { } diff --git a/renderer/viewer/three/chunkMeshManager.ts b/renderer/viewer/three/chunkMeshManager.ts new file mode 100644 index 00000000..3038eb9b --- /dev/null +++ b/renderer/viewer/three/chunkMeshManager.ts @@ -0,0 +1,298 @@ +import * as THREE from 'three' +import { MesherGeometryOutput } from '../lib/mesher/shared' + +export interface ChunkMeshPool { + mesh: THREE.Mesh + inUse: boolean + lastUsedTime: number + sectionKey?: string +} + +export class ChunkMeshManager { + private readonly meshPool: ChunkMeshPool[] = [] + private readonly activeSections = new Map() + private poolSize: number + private maxPoolSize: number + private minPoolSize: number + + // Performance tracking + private hits = 0 + private misses = 0 + + // Debug flag to bypass pooling + public bypassPooling = false + + constructor ( + public material: THREE.Material, + public worldHeight: number, + viewDistance = 3, + ) { + this.updateViewDistance(viewDistance) + + console.log(`ChunkMeshManager: Initializing with pool size ${this.poolSize} (min: ${this.minPoolSize}, max: ${this.maxPoolSize})`) + + this.initializePool() + } + + private initializePool () { + // Create initial pool + for (let i = 0; i < this.poolSize; i++) { + const geometry = new THREE.BufferGeometry() + const mesh = new THREE.Mesh(geometry, this.material) + mesh.visible = false + mesh.matrixAutoUpdate = false + mesh.name = 'pooled-section-mesh' + + const poolEntry: ChunkMeshPool = { + mesh, + inUse: false, + lastUsedTime: 0 + } + + this.meshPool.push(poolEntry) + // Don't add to scene here - meshes will be added to containers + } + } + + /** + * Update or create a section with new geometry data + */ + updateSection (sectionKey: string, geometryData: MesherGeometryOutput): THREE.Mesh | null { + // Normal pooling mode + // Check if section already exists + let poolEntry = this.activeSections.get(sectionKey) + + if (!poolEntry) { + // Get mesh from pool + poolEntry = this.acquireMesh() + if (!poolEntry) { + console.warn(`ChunkMeshManager: No available mesh in pool for section ${sectionKey}`) + return null + } + + this.activeSections.set(sectionKey, poolEntry) + poolEntry.sectionKey = sectionKey + } + + const { mesh } = poolEntry + const { geometry } = mesh + + // Update geometry attributes efficiently + this.updateGeometryAttribute(geometry, 'position', geometryData.positions, 3) + this.updateGeometryAttribute(geometry, 'normal', geometryData.normals, 3) + this.updateGeometryAttribute(geometry, 'color', geometryData.colors, 3) + this.updateGeometryAttribute(geometry, 'uv', geometryData.uvs, 2) + + // Use direct index assignment for better performance (like before) + geometry.index = new THREE.BufferAttribute(geometryData.indices as Uint32Array | Uint16Array, 1) + + // Set bounding box and sphere for the 16x16x16 section + geometry.boundingBox = new THREE.Box3( + new THREE.Vector3(-8, -8, -8), + new THREE.Vector3(8, 8, 8) + ) + geometry.boundingSphere = new THREE.Sphere( + new THREE.Vector3(0, 0, 0), + Math.sqrt(3 * 8 ** 2) + ) + + // Position the mesh + mesh.position.set(geometryData.sx, geometryData.sy, geometryData.sz) + mesh.updateMatrix() + mesh.visible = true + + // Store metadata + ;(mesh as any).tilesCount = geometryData.positions.length / 3 / 4 + ;(mesh as any).blocksCount = geometryData.blocksCount + + poolEntry.lastUsedTime = performance.now() + + return mesh + } + + /** + * Release a section and return its mesh to the pool + */ + releaseSection (sectionKey: string): boolean { + const poolEntry = this.activeSections.get(sectionKey) + if (!poolEntry) { + return false + } + + // Hide mesh and mark as available + poolEntry.mesh.visible = false + poolEntry.inUse = false + poolEntry.sectionKey = undefined + poolEntry.lastUsedTime = 0 + + // Clear geometry to free memory + this.clearGeometry(poolEntry.mesh.geometry) + + this.activeSections.delete(sectionKey) + + return true + } + + /** + * Get mesh for section if it exists + */ + getSectionMesh (sectionKey: string): THREE.Mesh | undefined { + return this.activeSections.get(sectionKey)?.mesh + } + + /** + * Check if section is managed by this pool + */ + hasSection (sectionKey: string): boolean { + return this.activeSections.has(sectionKey) + } + + /** + * Update pool size based on new view distance + */ + updateViewDistance (maxViewDistance: number) { + // Calculate dynamic pool size based on view distance + const chunksInView = (maxViewDistance * 2 + 1) ** 2 + const maxSectionsPerChunk = this.worldHeight / 16 + const avgSectionsPerChunk = 5 + this.minPoolSize = Math.floor(chunksInView * avgSectionsPerChunk) + this.maxPoolSize = Math.floor(chunksInView * maxSectionsPerChunk) + 1 + this.poolSize ??= this.minPoolSize + + // Expand pool if needed to reach optimal size + if (this.minPoolSize > this.poolSize) { + const targetSize = Math.min(this.minPoolSize, this.maxPoolSize) + this.expandPool(targetSize) + } + + console.log(`ChunkMeshManager: Updated view max distance to ${maxViewDistance}, pool: ${this.poolSize}/${this.maxPoolSize}, optimal: ${this.minPoolSize}`) + } + + /** + * Get pool statistics + */ + getStats () { + const freeCount = this.meshPool.filter(entry => !entry.inUse).length + const hitRate = this.hits + this.misses > 0 ? (this.hits / (this.hits + this.misses) * 100).toFixed(1) : '0' + + return { + poolSize: this.poolSize, + activeCount: this.activeSections.size, + freeCount, + hitRate: `${hitRate}%`, + hits: this.hits, + misses: this.misses + } + } + + /** + * Cleanup and dispose resources + */ + dispose () { + // Release all active sections + for (const [sectionKey] of this.activeSections) { + this.releaseSection(sectionKey) + } + + // Dispose all meshes and geometries + for (const poolEntry of this.meshPool) { + // Meshes will be removed from scene when their parent containers are removed + poolEntry.mesh.geometry.dispose() + } + + this.meshPool.length = 0 + this.activeSections.clear() + } + + // Private helper methods + + private acquireMesh (): ChunkMeshPool | undefined { + if (this.bypassPooling) { + return { + mesh: new THREE.Mesh(new THREE.BufferGeometry(), this.material), + inUse: true, + lastUsedTime: performance.now() + } + } + + // Find first available mesh + const availableMesh = this.meshPool.find(entry => !entry.inUse) + + if (availableMesh) { + availableMesh.inUse = true + this.hits++ + return availableMesh + } + + // No available mesh, expand pool to accommodate new sections + let newPoolSize = Math.min(this.poolSize + 16, this.maxPoolSize) + if (newPoolSize === this.poolSize) { + newPoolSize = this.poolSize + 8 + this.maxPoolSize = newPoolSize + console.warn(`ChunkMeshManager: Pool exhausted (${this.poolSize}/${this.maxPoolSize}). Emergency expansion to ${newPoolSize}`) + } + + this.misses++ + this.expandPool(newPoolSize) + return this.acquireMesh() + } + + private expandPool (newSize: number) { + const oldSize = this.poolSize + this.poolSize = newSize + + // console.log(`ChunkMeshManager: Expanding pool from ${oldSize} to ${newSize}`) + + // Add new meshes to pool + for (let i = oldSize; i < newSize; i++) { + const geometry = new THREE.BufferGeometry() + const mesh = new THREE.Mesh(geometry, this.material) + mesh.visible = false + mesh.matrixAutoUpdate = false + mesh.name = 'pooled-section-mesh' + + const poolEntry: ChunkMeshPool = { + mesh, + inUse: false, + lastUsedTime: 0 + } + + this.meshPool.push(poolEntry) + // Don't add to scene here - meshes will be added to containers + } + } + + private updateGeometryAttribute ( + geometry: THREE.BufferGeometry, + name: string, + array: Float32Array, + itemSize: number + ) { + const attribute = geometry.getAttribute(name) + + if (attribute && attribute.count === array.length / itemSize) { + // Reuse existing attribute + ;(attribute.array as Float32Array).set(array) + attribute.needsUpdate = true + } else { + // Create new attribute (this will dispose the old one automatically) + geometry.setAttribute(name, new THREE.BufferAttribute(array, itemSize)) + } + } + + private clearGeometry (geometry: THREE.BufferGeometry) { + // Clear attributes but keep the attribute objects for reuse + const attributes = ['position', 'normal', 'color', 'uv'] + for (const name of attributes) { + const attr = geometry.getAttribute(name) + if (attr) { + // Just mark as needing update but don't dispose to avoid recreation costs + attr.needsUpdate = true + } + } + + if (geometry.index) { + geometry.index.needsUpdate = true + } + } +} diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index ce21b1de..f3921f45 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -7,6 +7,7 @@ import { renderSign } from '../sign-renderer' import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer' import { chunkPos, sectionPos } from '../lib/simpleUtils' import { WorldRendererCommon } from '../lib/worldrendererCommon' +import { WorldDataEmitterWorker } from '../lib/worldDataEmitter' import { addNewStat } from '../lib/ui/newStats' import { MesherGeometryOutput, InstancingMode } from '../lib/mesher/shared' import { ItemSpecificContextProperties } from '../lib/basePlayerState' @@ -25,6 +26,7 @@ import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' import { Fountain } from './threeJsParticles' import { InstancedRenderer } from './instancedRenderer' +import { ChunkMeshManager } from './chunkMeshManager' type SectionKey = string @@ -53,6 +55,7 @@ export class WorldRendererThree extends WorldRendererCommon { cameraContainer: THREE.Object3D media: ThreeJsMedia instancedRenderer: InstancedRenderer | undefined + chunkMeshManager: ChunkMeshManager waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] } camera: THREE.PerspectiveCamera renderTimeAvg = 0 @@ -106,6 +109,14 @@ export class WorldRendererThree extends WorldRendererCommon { this.cameraShake = new CameraShake(this, this.onRender) this.media = new ThreeJsMedia(this) this.instancedRenderer = new InstancedRenderer(this) + this.chunkMeshManager = new ChunkMeshManager(this.material, this.worldSizeParams.worldHeight, this.viewDistance) + + // Enable bypass pooling for debugging if URL param is present + if (new URLSearchParams(location.search).get('bypassMeshPooling') === 'true') { + this.chunkMeshManager.bypassPooling = true + console.log('ChunkMeshManager: Bypassing pooling for debugging') + } + // this.fountain = new Fountain(this.scene, this.scene, { // position: new THREE.Vector3(0, 10, 0), // }) @@ -137,6 +148,15 @@ export class WorldRendererThree extends WorldRendererCommon { }) } + override connect (worldView: WorldDataEmitterWorker) { + super.connect(worldView) + + // Add additional renderDistance handling for mesh pool updates + worldView.on('renderDistance', (viewDistance) => { + this.chunkMeshManager.updateViewDistance(viewDistance) + }) + } + updateEntity (e, isPosUpdate = false) { const overrides = { rotation: { @@ -346,8 +366,11 @@ export class WorldRendererThree extends WorldRendererCommon { text += `B: ${formatBigNumber(this.blocksRendered)} ` if (instancedStats) { text += `I: ${formatBigNumber(instancedStats.totalInstances)}/${instancedStats.activeBlockTypes}t ` - text += `DC: ${formatBigNumber(instancedStats.drawCalls)}` + text += `DC: ${formatBigNumber(instancedStats.drawCalls)} ` } + const poolStats = this.chunkMeshManager.getStats() + const poolMode = this.chunkMeshManager.bypassPooling ? 'BYPASS' : poolStats.hitRate + text += `MP: ${poolStats.activeCount}/${poolStats.poolSize} ${poolMode}` pane.updateText(text) this.backendInfoReport = text } @@ -361,7 +384,7 @@ export class WorldRendererThree extends WorldRendererCommon { const [x, y, z] = key.split(',').map(x => Math.floor(+x / 16)) // sum of distances: x + y + z const chunkDistance = Math.abs(x - this.cameraSectionPos.x) + Math.abs(y - this.cameraSectionPos.y) + Math.abs(z - this.cameraSectionPos.z) - const section = this.sectionObjects[key].children.find(child => child.name === 'mesh')! + const section = this.sectionObjects[key].mesh! section.renderOrder = 500 - chunkDistance } @@ -402,7 +425,6 @@ export class WorldRendererThree extends WorldRendererCommon { delete this.waitingChunksToDisplay[chunkKey] } - // debugRecomputedDeletedObjects = 0 handleWorkerMessage (data: { geometry: MesherGeometryOutput, key, type }): void { if (data.type !== 'geometry') return @@ -413,55 +435,74 @@ export class WorldRendererThree extends WorldRendererCommon { this.instancedRenderer?.removeSectionInstances(data.key) - // if (data.key === '48,64,32') { - // console.log('handleWorkerMessage', data.key, this.sectionObjects[data.key], this.sectionInstancingMode[data.key], Object.keys(data.geometry.instancedBlocks).length, data.geometry.positions.length) - // } - // Handle instanced blocks data from worker if (hasInstancedBlocks) { this.instancedRenderer?.handleInstancedBlocksFromWorker(data.geometry.instancedBlocks, data.key, this.getInstancingMode(new Vec3(chunkCoords[0], chunkCoords[1], chunkCoords[2]))) } + // Check if chunk should be loaded and has geometry + if (!this.loadedChunks[chunkKey] || !data.geometry.positions.length || !this.active) { + // Release any existing section from the pool + this.chunkMeshManager.releaseSection(data.key) + return + } + + // remvoe object from scene let object = this.sectionObjects[data.key] if (object) { this.scene.remove(object) - disposeObject(object) + // disposeObject(object) delete this.sectionObjects[data.key] } - object = this.sectionObjects[data.key] + // Use ChunkMeshManager for optimized mesh handling + const mesh = this.chunkMeshManager.updateSection(data.key, data.geometry) - if (!this.loadedChunks[chunkKey] || !data.geometry.positions.length || !this.active) return + if (!mesh) { + console.warn(`Failed to get mesh for section ${data.key}`) + return + } - // if (object) { - // this.debugRecomputedDeletedObjects++ + // Create or update the section object container + object = new THREE.Group() + this.sectionObjects[data.key] = object + this.scene.add(object) + + // Add the pooled mesh to the container + object.add(mesh) + this.sectionObjects[data.key].mesh = mesh as THREE.Mesh + + // Handle signs and heads (these are added to the container, not the pooled mesh) + this.addSignsAndHeads(object as THREE.Group, data.geometry) + + this.updateBoxHelper(data.key) + + // Handle chunk-based rendering + if (this.displayOptions.inWorldRenderingConfig._renderByChunks) { + object.visible = false + const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}` + this.waitingChunksToDisplay[chunkKey] ??= [] + this.waitingChunksToDisplay[chunkKey].push(data.key) + if (this.finishedChunks[chunkKey]) { + this.finishChunk(chunkKey) + } + } + + this.updatePosDataChunk(data.key) + object.matrixAutoUpdate = false + } + + private addSignsAndHeads (object: THREE.Group, geometry: MesherGeometryOutput) { + // Clear existing signs and heads + // const childrenToRemove = object.children.filter(child => child.name !== 'mesh' && child.name !== 'helper') + // for (const child of childrenToRemove) { + // object.remove(child) + // disposeObject(child) // } - const geometry = object?.mesh?.geometry ?? new THREE.BufferGeometry() - geometry.setAttribute('position', new THREE.BufferAttribute(data.geometry.positions, 3)) - geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3)) - geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3)) - geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2)) - geometry.index = new THREE.BufferAttribute(data.geometry.indices as Uint32Array | Uint16Array, 1) - const { sx, sy, sz } = data.geometry - - // Set bounding box for the 16x16x16 section - geometry.boundingBox = new THREE.Box3(new THREE.Vector3(-8, -8, -8), new THREE.Vector3(8, 8, 8)) - geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), Math.sqrt(3 * 8 ** 2)) - - const mesh = object?.mesh ?? new THREE.Mesh(geometry, this.material) - mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz) - mesh.name = 'mesh' - if (!object) { - object = new THREE.Group() - object.add(mesh) - } - (object as any).tilesCount = data.geometry.positions.length / 3 / 4; - (object as any).blocksCount = data.geometry.blocksCount - - // should not compute it once - if (Object.keys(data.geometry.signs).length) { - for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(data.geometry.signs)) { + // Add signs + if (Object.keys(geometry.signs).length) { + for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(geometry.signs)) { const signBlockEntity = this.blockEntities[posKey] if (!signBlockEntity) continue const [x, y, z] = posKey.split(',') @@ -470,8 +511,10 @@ export class WorldRendererThree extends WorldRendererCommon { object.add(sign) } } - if (Object.keys(data.geometry.heads).length) { - for (const [posKey, { isWall, rotation }] of Object.entries(data.geometry.heads)) { + + // Add heads + if (Object.keys(geometry.heads).length) { + for (const [posKey, { isWall, rotation }] of Object.entries(geometry.heads)) { const headBlockEntity = this.blockEntities[posKey] if (!headBlockEntity) continue const [x, y, z] = posKey.split(',') @@ -480,31 +523,6 @@ export class WorldRendererThree extends WorldRendererCommon { object.add(head) } } - - if (!this.sectionObjects[data.key]) { - this.sectionObjects[data.key] = object - this.sectionObjects[data.key].mesh = mesh - this.scene.add(object) - } - - this.updateBoxHelper(data.key) - - if (this.displayOptions.inWorldRenderingConfig._renderByChunks) { - object.visible = false - const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}` - this.waitingChunksToDisplay[chunkKey] ??= [] - this.waitingChunksToDisplay[chunkKey].push(data.key) - if (this.finishedChunks[chunkKey]) { - // todo it might happen even when it was not an update - this.finishChunk(chunkKey) - } - } - - this.updatePosDataChunk(data.key) - object.matrixAutoUpdate = false - mesh.onAfterRender = (renderer, scene, camera, geometry, material, group) => { - // mesh.matrixAutoUpdate = false - } } getSignTexture (position: Vec3, blockEntity, backSide = false) { @@ -1031,10 +1049,13 @@ export class WorldRendererThree extends WorldRendererCommon { // Remove instanced blocks for this section this.instancedRenderer?.removeSectionInstances(key) - const mesh = this.sectionObjects[key] - if (mesh) { - this.scene.remove(mesh) - disposeObject(mesh) + // Release section from mesh pool + this.chunkMeshManager.releaseSection(key) + + const object = this.sectionObjects[key] + if (object) { + this.scene.remove(object) + disposeObject(object) } delete this.sectionObjects[key] } @@ -1089,6 +1110,7 @@ export class WorldRendererThree extends WorldRendererCommon { destroy (): void { this.instancedRenderer?.destroy() + this.chunkMeshManager.dispose() super.destroy() } From 2f49cbb35be5c3d16947b1f8f697cbba71027c99 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 20 Jul 2025 09:24:57 +0300 Subject: [PATCH 21/86] finish manager! --- experiments/three.ts | 218 +++++++-- renderer/viewer/lib/worldrendererCommon.ts | 1 + renderer/viewer/three/chunkMeshManager.ts | 483 +++++++++++++++++++- renderer/viewer/three/worldrendererThree.ts | 261 ++--------- src/controls.ts | 4 +- src/defaultOptions.ts | 1 + src/watchOptions.ts | 4 + 7 files changed, 696 insertions(+), 276 deletions(-) diff --git a/experiments/three.ts b/experiments/three.ts index 9b158dec..63e08d9e 100644 --- a/experiments/three.ts +++ b/experiments/three.ts @@ -1,5 +1,5 @@ import * as THREE from 'three' -import { loadThreeJsTextureFromBitmap } from '../renderer/viewer/lib/utils/skins' +import globalTexture from 'mc-assets/dist/blocksAtlasLegacy.png' // Create scene, camera and renderer const scene = new THREE.Scene() @@ -9,53 +9,193 @@ renderer.setSize(window.innerWidth, window.innerHeight) document.body.appendChild(renderer.domElement) // Position camera -camera.position.z = 5 +camera.position.set(3, 3, 3) +camera.lookAt(0, 0, 0) -// Create a canvas with some content -const canvas = document.createElement('canvas') -canvas.width = 256 -canvas.height = 256 -const ctx = canvas.getContext('2d') +// Dark background +scene.background = new THREE.Color(0x333333) -scene.background = new THREE.Color(0x444444) +// Add some lighting +const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) +scene.add(ambientLight) +const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4) +directionalLight.position.set(1, 1, 1) +scene.add(directionalLight) -// Draw something on the canvas -ctx.fillStyle = '#444444' -// ctx.fillRect(0, 0, 256, 256) -ctx.fillStyle = 'red' -ctx.font = '48px Arial' -ctx.textAlign = 'center' -ctx.textBaseline = 'middle' -ctx.fillText('Hello!', 128, 128) +// Create shared material that will be used by all blocks +const sharedMaterial = new THREE.MeshLambertMaterial({ + vertexColors: true, + transparent: true, + alphaTest: 0.1 +}) -// Create bitmap and texture -async function createTexturedBox() { - const canvas2 = new OffscreenCanvas(256, 256) - const ctx2 = canvas2.getContext('2d')! - ctx2.drawImage(canvas, 0, 0) - const texture = new THREE.Texture(canvas2) +function createCustomGeometry(textureInfo: { u: number, v: number, su: number, sv: number }): THREE.BufferGeometry { + // Create custom geometry with specific UV coordinates for this block type + const geometry = new THREE.BoxGeometry(1, 1, 1) + + // Get UV attribute + const uvAttribute = geometry.getAttribute('uv') as THREE.BufferAttribute + const uvs = uvAttribute.array as Float32Array + + console.log('Original UVs:', Array.from(uvs)) + console.log('Texture info:', textureInfo) + + // BoxGeometry has 6 faces, each with 2 triangles (4 vertices), so 24 UV pairs total + // Apply the same texture to all faces for simplicity + for (let i = 0; i < uvs.length; i += 2) { + const u = uvs[i] + const v = uvs[i + 1] + + // Map from 0-1 to the specific texture region in the atlas + uvs[i] = textureInfo.u + u * textureInfo.su + uvs[i + 1] = textureInfo.v + v * textureInfo.sv + } + + console.log('Modified UVs:', Array.from(uvs)) + uvAttribute.needsUpdate = true + return geometry +} + +let currentInstancedMesh: THREE.InstancedMesh | null = null +let currentRefCube: THREE.Mesh | null = null + +async function createInstancedBlock() { + try { + // Clean up previous meshes if they exist + if (currentInstancedMesh) { + scene.remove(currentInstancedMesh) + currentInstancedMesh.geometry.dispose() + } + if (currentRefCube) { + scene.remove(currentRefCube) + currentRefCube.geometry.dispose() + } + + // Load the blocks atlas texture + const textureLoader = new THREE.TextureLoader() + const texture = await new Promise((resolve, reject) => { + textureLoader.load( + globalTexture, + resolve, + undefined, + reject + ) + }) + + // Configure texture for pixel art texture.magFilter = THREE.NearestFilter texture.minFilter = THREE.NearestFilter - texture.needsUpdate = true + texture.generateMipmaps = false texture.flipY = false - // Create box with texture - const geometry = new THREE.BoxGeometry(2, 2, 2) - const material = new THREE.MeshBasicMaterial({ - map: texture, - side: THREE.DoubleSide, - premultipliedAlpha: false, - }) - const cube = new THREE.Mesh(geometry, material) - scene.add(cube) + // Set the texture on our shared material + sharedMaterial.map = texture + sharedMaterial.needsUpdate = true + + console.log('Texture loaded:', texture.image.width, 'x', texture.image.height) + + // Calculate UV coordinates for the first tile (top-left, 16x16) + const atlasWidth = texture.image.width + const atlasHeight = texture.image.height + const tileSize = 16 + + const textureInfo = { + u: 0 / atlasWidth, // Left edge (first column) + v: 2 * tileSize / atlasHeight, // Top edge (first row) + su: tileSize / atlasWidth, // Width of one tile + sv: tileSize / atlasHeight // Height of one tile + } + + console.log('Atlas size:', atlasWidth, 'x', atlasHeight) + console.log('Calculated texture info:', textureInfo) + + // Create custom geometry with proper UV mapping + const geometry = createCustomGeometry(textureInfo) + + // Create instanced mesh using shared material + currentInstancedMesh = new THREE.InstancedMesh(geometry, sharedMaterial, 1) + const matrix = new THREE.Matrix4() + matrix.setPosition(0.5, 0.5, 0.5) // Offset by +0.5 on each axis + currentInstancedMesh.setMatrixAt(0, matrix) + currentInstancedMesh.count = 1 + currentInstancedMesh.instanceMatrix.needsUpdate = true + scene.add(currentInstancedMesh) + + // Reference non-instanced cube using same material + currentRefCube = new THREE.Mesh(geometry, sharedMaterial) + currentRefCube.position.set(2.5, 0.5, 0.5) // Offset by +0.5 on each axis + scene.add(currentRefCube) + + console.log('Instanced block created successfully') + + } catch (error) { + console.error('Error creating instanced block:', error) + + // Fallback: create a colored cube + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshLambertMaterial({ color: 0xff0000 }) + currentRefCube = new THREE.Mesh(geometry, material) + scene.add(currentRefCube) + console.log('Created fallback colored cube') + } } -// Create the textured box -createTexturedBox() +// Create the instanced block +createInstancedBlock() -// Animation loop -function animate() { - requestAnimationFrame(animate) - renderer.render(scene, camera) +// Simple render loop (no animation) +function render() { + renderer.render(scene, camera) } -animate() + +// Add mouse controls for better viewing +let mouseDown = false +let mouseX = 0 +let mouseY = 0 + +renderer.domElement.addEventListener('mousedown', (event) => { + mouseDown = true + mouseX = event.clientX + mouseY = event.clientY +}) + +renderer.domElement.addEventListener('mousemove', (event) => { + if (!mouseDown) return + + const deltaX = event.clientX - mouseX + const deltaY = event.clientY - mouseY + + // Rotate camera around the cube + const spherical = new THREE.Spherical() + spherical.setFromVector3(camera.position) + spherical.theta -= deltaX * 0.01 + spherical.phi += deltaY * 0.01 + spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)) + + camera.position.setFromSpherical(spherical) + camera.lookAt(0, 0, 0) + + mouseX = event.clientX + mouseY = event.clientY + + render() +}) + +renderer.domElement.addEventListener('mouseup', () => { + mouseDown = false +}) + +// Add button to recreate blocks (for testing) +const button = document.createElement('button') +button.textContent = 'Recreate Blocks' +button.style.position = 'fixed' +button.style.top = '10px' +button.style.left = '10px' +button.addEventListener('click', () => { + createInstancedBlock() + render() +}) +document.body.appendChild(button) + +// Initial render +render() diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index 2ac7af86..7db0acc3 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -65,6 +65,7 @@ export const defaultWorldRendererConfig = { dynamicColorModeDistance: 1, // chunks beyond this distance use color mode only instancedOnlyDistance: 6, // chunks beyond this distance use instancing only enableSingleColorMode: false, // ultra-performance mode with solid colors + autoLowerRenderDistance: false, } export type WorldRendererConfig = typeof defaultWorldRendererConfig diff --git a/renderer/viewer/three/chunkMeshManager.ts b/renderer/viewer/three/chunkMeshManager.ts index 3038eb9b..a5ac58a6 100644 --- a/renderer/viewer/three/chunkMeshManager.ts +++ b/renderer/viewer/three/chunkMeshManager.ts @@ -1,5 +1,14 @@ +import PrismarineChatLoader from 'prismarine-chat' import * as THREE from 'three' +import * as nbt from 'prismarine-nbt' +import { Vec3 } from 'vec3' import { MesherGeometryOutput } from '../lib/mesher/shared' +import { chunkPos } from '../lib/simpleUtils' +import { renderSign } from '../sign-renderer' +import { getMesh } from './entity/EntityMesh' +import type { WorldRendererThree } from './worldrendererThree' +import { armorModel } from './entity/armorModels' +import { disposeObject } from './threeJsUtils' export interface ChunkMeshPool { mesh: THREE.Mesh @@ -8,12 +17,25 @@ export interface ChunkMeshPool { sectionKey?: string } +export interface SectionObject extends THREE.Group { + mesh?: THREE.Mesh + tilesCount?: number + blocksCount?: number + + signsContainer?: THREE.Group + headsContainer?: THREE.Group + boxHelper?: THREE.BoxHelper + fountain?: boolean +} + export class ChunkMeshManager { private readonly meshPool: ChunkMeshPool[] = [] private readonly activeSections = new Map() + readonly sectionObjects: Record = {} private poolSize: number private maxPoolSize: number private minPoolSize: number + private readonly signHeadsRenderer: SignHeadsRenderer // Performance tracking private hits = 0 @@ -22,14 +44,30 @@ export class ChunkMeshManager { // Debug flag to bypass pooling public bypassPooling = false + // Performance monitoring + private readonly renderTimes: number[] = [] + private readonly maxRenderTimeSamples = 30 + private _performanceOverrideDistance?: number + private lastPerformanceCheck = 0 + private readonly performanceCheckInterval = 2000 // Check every 2 seconds + + get performanceOverrideDistance () { + return this._performanceOverrideDistance ?? 0 + } + set performanceOverrideDistance (value: number | undefined) { + this._performanceOverrideDistance = value + this.updateSectionsVisibility() + } + constructor ( + public worldRenderer: WorldRendererThree, + public scene: THREE.Scene, public material: THREE.Material, public worldHeight: number, viewDistance = 3, ) { this.updateViewDistance(viewDistance) - - console.log(`ChunkMeshManager: Initializing with pool size ${this.poolSize} (min: ${this.minPoolSize}, max: ${this.maxPoolSize})`) + this.signHeadsRenderer = new SignHeadsRenderer(worldRenderer) this.initializePool() } @@ -57,13 +95,16 @@ export class ChunkMeshManager { /** * Update or create a section with new geometry data */ - updateSection (sectionKey: string, geometryData: MesherGeometryOutput): THREE.Mesh | null { - // Normal pooling mode - // Check if section already exists - let poolEntry = this.activeSections.get(sectionKey) + updateSection (sectionKey: string, geometryData: MesherGeometryOutput): SectionObject | null { + // Remove existing section object from scene if it exists + let sectionObject = this.sectionObjects[sectionKey] + if (sectionObject) { + this.cleanupSection(sectionKey) + } + // Get or create mesh from pool + let poolEntry = this.activeSections.get(sectionKey) if (!poolEntry) { - // Get mesh from pool poolEntry = this.acquireMesh() if (!poolEntry) { console.warn(`ChunkMeshManager: No available mesh in pool for section ${sectionKey}`) @@ -75,23 +116,22 @@ export class ChunkMeshManager { } const { mesh } = poolEntry - const { geometry } = mesh // Update geometry attributes efficiently - this.updateGeometryAttribute(geometry, 'position', geometryData.positions, 3) - this.updateGeometryAttribute(geometry, 'normal', geometryData.normals, 3) - this.updateGeometryAttribute(geometry, 'color', geometryData.colors, 3) - this.updateGeometryAttribute(geometry, 'uv', geometryData.uvs, 2) + this.updateGeometryAttribute(mesh.geometry, 'position', geometryData.positions, 3) + this.updateGeometryAttribute(mesh.geometry, 'normal', geometryData.normals, 3) + this.updateGeometryAttribute(mesh.geometry, 'color', geometryData.colors, 3) + this.updateGeometryAttribute(mesh.geometry, 'uv', geometryData.uvs, 2) // Use direct index assignment for better performance (like before) - geometry.index = new THREE.BufferAttribute(geometryData.indices as Uint32Array | Uint16Array, 1) + mesh.geometry.index = new THREE.BufferAttribute(geometryData.indices as Uint32Array | Uint16Array, 1) // Set bounding box and sphere for the 16x16x16 section - geometry.boundingBox = new THREE.Box3( + mesh.geometry.boundingBox = new THREE.Box3( new THREE.Vector3(-8, -8, -8), new THREE.Vector3(8, 8, 8) ) - geometry.boundingSphere = new THREE.Sphere( + mesh.geometry.boundingSphere = new THREE.Sphere( new THREE.Vector3(0, 0, 0), Math.sqrt(3 * 8 ** 2) ) @@ -100,20 +140,81 @@ export class ChunkMeshManager { mesh.position.set(geometryData.sx, geometryData.sy, geometryData.sz) mesh.updateMatrix() mesh.visible = true - - // Store metadata - ;(mesh as any).tilesCount = geometryData.positions.length / 3 / 4 - ;(mesh as any).blocksCount = geometryData.blocksCount + mesh.name = 'mesh' poolEntry.lastUsedTime = performance.now() - return mesh + // Create or update the section object container + sectionObject = new THREE.Group() as SectionObject + sectionObject.add(mesh) + sectionObject.mesh = mesh as THREE.Mesh + + // Store metadata + sectionObject.tilesCount = geometryData.positions.length / 3 / 4 + sectionObject.blocksCount = geometryData.blocksCount + + // Add signs container + if (Object.keys(geometryData.signs).length > 0) { + const signsContainer = new THREE.Group() + signsContainer.name = 'signs' + for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(geometryData.signs)) { + const signBlockEntity = this.worldRenderer.blockEntities[posKey] + if (!signBlockEntity) continue + const [x, y, z] = posKey.split(',') + const sign = this.signHeadsRenderer.renderSign(new Vec3(+x, +y, +z), rotation, isWall, isHanging, nbt.simplify(signBlockEntity)) + if (!sign) continue + signsContainer.add(sign) + } + sectionObject.add(signsContainer) + sectionObject.signsContainer = signsContainer + } + + // Add heads container + if (Object.keys(geometryData.heads).length > 0) { + const headsContainer = new THREE.Group() + headsContainer.name = 'heads' + for (const [posKey, { isWall, rotation }] of Object.entries(geometryData.heads)) { + const headBlockEntity = this.worldRenderer.blockEntities[posKey] + if (!headBlockEntity) continue + const [x, y, z] = posKey.split(',') + const head = this.signHeadsRenderer.renderHead(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(headBlockEntity)) + if (!head) continue + headsContainer.add(head) + } + sectionObject.add(headsContainer) + sectionObject.headsContainer = headsContainer + } + + // Store and add to scene + this.sectionObjects[sectionKey] = sectionObject + this.scene.add(sectionObject) + sectionObject.matrixAutoUpdate = false + + return sectionObject + } + + cleanupSection (sectionKey: string) { + // Remove section object from scene + const sectionObject = this.sectionObjects[sectionKey] + if (sectionObject) { + this.scene.remove(sectionObject) + // Dispose signs and heads containers + if (sectionObject.signsContainer) { + this.disposeContainer(sectionObject.signsContainer) + } + if (sectionObject.headsContainer) { + this.disposeContainer(sectionObject.headsContainer) + } + delete this.sectionObjects[sectionKey] + } } /** * Release a section and return its mesh to the pool */ releaseSection (sectionKey: string): boolean { + this.cleanupSection(sectionKey) + const poolEntry = this.activeSections.get(sectionKey) if (!poolEntry) { return false @@ -130,9 +231,43 @@ export class ChunkMeshManager { this.activeSections.delete(sectionKey) + // Memory cleanup: if pool exceeds max size and we have free meshes, remove one + this.cleanupExcessMeshes() + return true } + /** + * Get section object if it exists + */ + getSectionObject (sectionKey: string): SectionObject | undefined { + return this.sectionObjects[sectionKey] + } + + /** + * Update box helper for a section + */ + updateBoxHelper (sectionKey: string, showChunkBorders: boolean, chunkBoxMaterial: THREE.Material) { + const sectionObject = this.sectionObjects[sectionKey] + if (!sectionObject?.mesh) return + + if (showChunkBorders) { + if (!sectionObject.boxHelper) { + // mesh with static dimensions: 16x16x16 + const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), chunkBoxMaterial) + staticChunkMesh.position.copy(sectionObject.mesh.position) + const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00) + boxHelper.name = 'helper' + sectionObject.add(boxHelper) + sectionObject.name = 'chunk' + sectionObject.boxHelper = boxHelper + } + sectionObject.boxHelper.visible = true + } else if (sectionObject.boxHelper) { + sectionObject.boxHelper.visible = false + } + } + /** * Get mesh for section if it exists */ @@ -174,6 +309,7 @@ export class ChunkMeshManager { getStats () { const freeCount = this.meshPool.filter(entry => !entry.inUse).length const hitRate = this.hits + this.misses > 0 ? (this.hits / (this.hits + this.misses) * 100).toFixed(1) : '0' + const memoryUsage = this.getEstimatedMemoryUsage() return { poolSize: this.poolSize, @@ -181,7 +317,87 @@ export class ChunkMeshManager { freeCount, hitRate: `${hitRate}%`, hits: this.hits, - misses: this.misses + misses: this.misses, + memoryUsage + } + } + + /** + * Get total tiles rendered + */ + getTotalTiles (): number { + return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj.tilesCount || 0), 0) + } + + /** + * Get total blocks rendered + */ + getTotalBlocks (): number { + return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj.blocksCount || 0), 0) + } + + /** + * Estimate memory usage in MB + */ + getEstimatedMemoryUsage (): { total: string, breakdown: any } { + let totalBytes = 0 + let positionBytes = 0 + let normalBytes = 0 + let colorBytes = 0 + let uvBytes = 0 + let indexBytes = 0 + + for (const poolEntry of this.meshPool) { + if (poolEntry.inUse && poolEntry.mesh.geometry) { + const { geometry } = poolEntry.mesh + + const position = geometry.getAttribute('position') + if (position) { + const bytes = position.array.byteLength + positionBytes += bytes + totalBytes += bytes + } + + const normal = geometry.getAttribute('normal') + if (normal) { + const bytes = normal.array.byteLength + normalBytes += bytes + totalBytes += bytes + } + + const color = geometry.getAttribute('color') + if (color) { + const bytes = color.array.byteLength + colorBytes += bytes + totalBytes += bytes + } + + const uv = geometry.getAttribute('uv') + if (uv) { + const bytes = uv.array.byteLength + uvBytes += bytes + totalBytes += bytes + } + + if (geometry.index) { + const bytes = geometry.index.array.byteLength + indexBytes += bytes + totalBytes += bytes + } + } + } + + const totalMB = (totalBytes / (1024 * 1024)).toFixed(2) + + return { + total: `${totalMB} MB`, + breakdown: { + position: `${(positionBytes / (1024 * 1024)).toFixed(2)} MB`, + normal: `${(normalBytes / (1024 * 1024)).toFixed(2)} MB`, + color: `${(colorBytes / (1024 * 1024)).toFixed(2)} MB`, + uv: `${(uvBytes / (1024 * 1024)).toFixed(2)} MB`, + index: `${(indexBytes / (1024 * 1024)).toFixed(2)} MB`, + } } } @@ -295,4 +511,229 @@ export class ChunkMeshManager { geometry.index.needsUpdate = true } } + + private cleanupExcessMeshes () { + // If pool size exceeds max and we have free meshes, remove some + if (this.poolSize > this.maxPoolSize) { + const freeCount = this.meshPool.filter(entry => !entry.inUse).length + if (freeCount > 0) { + const excessCount = Math.min(this.poolSize - this.maxPoolSize, freeCount) + for (let i = 0; i < excessCount; i++) { + const freeIndex = this.meshPool.findIndex(entry => !entry.inUse) + if (freeIndex !== -1) { + const poolEntry = this.meshPool[freeIndex] + poolEntry.mesh.geometry.dispose() + this.meshPool.splice(freeIndex, 1) + this.poolSize-- + } + } + // console.log(`ChunkMeshManager: Cleaned up ${excessCount} excess meshes. Pool size: ${this.poolSize}/${this.maxPoolSize}`) + } + } + } + + private disposeContainer (container: THREE.Group) { + disposeObject(container, true) + } + + /** + * Record render time for performance monitoring + */ + recordRenderTime (renderTime: number): void { + this.renderTimes.push(renderTime) + if (this.renderTimes.length > this.maxRenderTimeSamples) { + this.renderTimes.shift() + } + + // Check performance periodically + const now = performance.now() + if (now - this.lastPerformanceCheck > this.performanceCheckInterval) { + this.checkPerformance() + this.lastPerformanceCheck = now + } + } + + /** + * Get current effective render distance + */ + getEffectiveRenderDistance (): number { + return this.performanceOverrideDistance || this.worldRenderer.viewDistance + } + + /** + * Force reset performance override + */ + resetPerformanceOverride (): void { + this.performanceOverrideDistance = undefined + this.renderTimes.length = 0 + console.log('ChunkMeshManager: Performance override reset') + } + + /** + * Get average render time + */ + getAverageRenderTime (): number { + if (this.renderTimes.length === 0) return 0 + return this.renderTimes.reduce((sum, time) => sum + time, 0) / this.renderTimes.length + } + + /** + * Check if performance is degraded and adjust render distance + */ + private checkPerformance (): void { + if (this.renderTimes.length < this.maxRenderTimeSamples) return + + const avgRenderTime = this.getAverageRenderTime() + const targetRenderTime = 16.67 // 60 FPS target (16.67ms per frame) + const performanceThreshold = targetRenderTime * 1.5 // 25ms threshold + + if (avgRenderTime > performanceThreshold) { + // Performance is bad, reduce render distance + const currentViewDistance = this.worldRenderer.viewDistance + const newDistance = Math.max(1, Math.floor(currentViewDistance * 0.8)) + + if (!this.performanceOverrideDistance || newDistance < this.performanceOverrideDistance) { + this.performanceOverrideDistance = newDistance + console.warn(`ChunkMeshManager: Performance degraded (${avgRenderTime.toFixed(2)}ms avg). Reducing effective render distance to ${newDistance}`) + } + } else if (this.performanceOverrideDistance && avgRenderTime < targetRenderTime * 1.1) { + // Performance is good, gradually restore render distance + const currentViewDistance = this.worldRenderer.viewDistance + const newDistance = Math.min(currentViewDistance, this.performanceOverrideDistance + 1) + + if (newDistance !== this.performanceOverrideDistance) { + this.performanceOverrideDistance = newDistance >= currentViewDistance ? undefined : newDistance + console.log(`ChunkMeshManager: Performance improved. Restoring render distance to ${newDistance}`) + } + } + } + + /** + * Hide sections beyond performance override distance + */ + updateSectionsVisibility (): void { + const cameraPos = this.worldRenderer.cameraSectionPos + for (const [sectionKey, sectionObject] of Object.entries(this.sectionObjects)) { + if (!this.performanceOverrideDistance) { + sectionObject.visible = true + continue + } + + const [x, y, z] = sectionKey.split(',').map(Number) + const sectionPos = { x: x / 16, y: y / 16, z: z / 16 } + + // Calculate distance using hypot (same as render distance calculation) + const dx = sectionPos.x - cameraPos.x + const dz = sectionPos.z - cameraPos.z + const distance = Math.floor(Math.hypot(dx, dz)) + + sectionObject.visible = distance <= this.performanceOverrideDistance + } + } +} + + +class SignHeadsRenderer { + chunkTextures = new Map() + + constructor (public worldRendererThree: WorldRendererThree) { + } + + renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) { + const textures = blockEntity.SkullOwner?.Properties?.textures[0] + if (!textures) return + + try { + const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString()) + let skinUrl = textureData.textures?.SKIN?.url + const { skinTexturesProxy } = this.worldRendererThree.worldRendererConfig + if (skinTexturesProxy) { + skinUrl = skinUrl?.replace('http://textures.minecraft.net/', skinTexturesProxy) + .replace('https://textures.minecraft.net/', skinTexturesProxy) + } + + const mesh = getMesh(this.worldRendererThree, skinUrl, armorModel.head) + const group = new THREE.Group() + if (isWall) { + mesh.position.set(0, 0.3125, 0.3125) + } + // move head model down as armor have a different offset than blocks + mesh.position.y -= 23 / 16 + group.add(mesh) + group.position.set(position.x + 0.5, position.y + 0.045, position.z + 0.5) + group.rotation.set( + 0, + -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)), + 0 + ) + group.scale.set(0.8, 0.8, 0.8) + return group + } catch (err) { + console.error('Error decoding player texture:', err) + } + } + + renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { + const tex = this.getSignTexture(position, blockEntity) + + if (!tex) return + + // todo implement + // const key = JSON.stringify({ position, rotation, isWall }) + // if (this.signsCache.has(key)) { + // console.log('cached', key) + // } else { + // this.signsCache.set(key, tex) + // } + + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: tex, transparent: true })) + mesh.renderOrder = 999 + + const lineHeight = 7 / 16 + const scaleFactor = isHanging ? 1.3 : 1 + mesh.scale.set(1 * scaleFactor, lineHeight * scaleFactor, 1 * scaleFactor) + + const thickness = (isHanging ? 2 : 1.5) / 16 + const wallSpacing = 0.25 / 16 + if (isWall && !isHanging) { + mesh.position.set(0, 0, -0.5 + thickness + wallSpacing + 0.0001) + } else { + mesh.position.set(0, 0, thickness / 2 + 0.0001) + } + + const group = new THREE.Group() + group.rotation.set( + 0, + -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)), + 0 + ) + group.add(mesh) + const height = (isHanging ? 10 : 8) / 16 + const heightOffset = (isHanging ? 0 : isWall ? 4.333 : 9.333) / 16 + const textPosition = height / 2 + heightOffset + group.position.set(position.x + 0.5, position.y + textPosition, position.z + 0.5) + return group + } + + getSignTexture (position: Vec3, blockEntity, backSide = false) { + const chunk = chunkPos(position) + let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`) + if (!textures) { + textures = {} + this.chunkTextures.set(`${chunk[0]},${chunk[1]}`, textures) + } + const texturekey = `${position.x},${position.y},${position.z}` + // todo investigate bug and remove this so don't need to clean in section dirty + if (textures[texturekey]) return textures[texturekey] + + const PrismarineChat = PrismarineChatLoader(this.worldRendererThree.version) + const canvas = renderSign(blockEntity, PrismarineChat) + if (!canvas) return + const tex = new THREE.Texture(canvas) + tex.magFilter = THREE.NearestFilter + tex.minFilter = THREE.NearestFilter + tex.needsUpdate = true + textures[texturekey] = tex + return tex + } } diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index f3921f45..24d09f6f 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -1,11 +1,8 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' -import nbt from 'prismarine-nbt' -import PrismarineChatLoader from 'prismarine-chat' import * as tweenJs from '@tweenjs/tween.js' -import { renderSign } from '../sign-renderer' import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer' -import { chunkPos, sectionPos } from '../lib/simpleUtils' +import { sectionPos } from '../lib/simpleUtils' import { WorldRendererCommon } from '../lib/worldrendererCommon' import { WorldDataEmitterWorker } from '../lib/worldDataEmitter' import { addNewStat } from '../lib/ui/newStats' @@ -15,8 +12,6 @@ import { getMyHand } from '../lib/hand' import { setBlockPosition } from '../lib/mesher/standaloneRenderer' import { loadThreeJsTextureFromBitmap } from '../lib/utils/skins' import HoldingBlock from './holdingBlock' -import { getMesh } from './entity/EntityMesh' -import { armorModel } from './entity/armorModels' import { disposeObject } from './threeJsUtils' import { CursorBlock } from './world/cursorBlock' import { getItemUv } from './appShared' @@ -32,7 +27,6 @@ type SectionKey = string export class WorldRendererThree extends WorldRendererCommon { outputFormat = 'threeJs' as const - sectionObjects: Record }> = {} sectionInstancingMode: Record = {} chunkTextures = new Map() signsCache = new Map() @@ -85,11 +79,11 @@ export class WorldRendererThree extends WorldRendererCommon { private readonly worldOffset = new THREE.Vector3() get tilesRendered () { - return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0) + return this.chunkMeshManager.getTotalTiles() } get blocksRendered () { - return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).blocksCount, 0) + return this.chunkMeshManager.getTotalBlocks() } constructor (public renderer: THREE.WebGLRenderer, public initOptions: GraphicsInitOptions, public displayOptions: DisplayWorldOptions) { @@ -109,7 +103,7 @@ export class WorldRendererThree extends WorldRendererCommon { this.cameraShake = new CameraShake(this, this.onRender) this.media = new ThreeJsMedia(this) this.instancedRenderer = new InstancedRenderer(this) - this.chunkMeshManager = new ChunkMeshManager(this.material, this.worldSizeParams.worldHeight, this.viewDistance) + this.chunkMeshManager = new ChunkMeshManager(this, this.realScene, this.material, this.worldSizeParams.worldHeight, this.viewDistance) // Enable bypass pooling for debugging if URL param is present if (new URLSearchParams(location.search).get('bypassMeshPooling') === 'true') { @@ -245,6 +239,12 @@ export class WorldRendererThree extends WorldRendererCommon { this.onReactiveConfigUpdated('showChunkBorders', (value) => { this.updateShowChunksBorder() }) + this.onReactiveConfigUpdated('enableDebugOverlay', (value) => { + if (!value) { + // restore visibility + this.chunkMeshManager.updateSectionsVisibility() + } + }) } changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) { @@ -384,8 +384,8 @@ export class WorldRendererThree extends WorldRendererCommon { const [x, y, z] = key.split(',').map(x => Math.floor(+x / 16)) // sum of distances: x + y + z const chunkDistance = Math.abs(x - this.cameraSectionPos.x) + Math.abs(y - this.cameraSectionPos.y) + Math.abs(z - this.cameraSectionPos.z) - const section = this.sectionObjects[key].mesh! - section.renderOrder = 500 - chunkDistance + const sectionObject = this.chunkMeshManager.getSectionObject(key)! + sectionObject.renderOrder = 500 - chunkDistance } override updateViewerPosition (pos: Vec3): void { @@ -395,8 +395,8 @@ export class WorldRendererThree extends WorldRendererCommon { cameraSectionPositionUpdate () { // eslint-disable-next-line guard-for-in for (const key in this.sectionInstancingMode) { - const object = this.sectionObjects[key] - if (object) { + const sectionObject = this.chunkMeshManager.getSectionObject(key)! + if (sectionObject) { this.updatePosDataChunk(key) } @@ -420,7 +420,10 @@ export class WorldRendererThree extends WorldRendererCommon { finishChunk (chunkKey: string) { for (const sectionKey of this.waitingChunksToDisplay[chunkKey] ?? []) { - this.sectionObjects[sectionKey].visible = true + const sectionObject = this.chunkMeshManager.getSectionObject(sectionKey) + if (sectionObject) { + sectionObject.visible = true + } } delete this.waitingChunksToDisplay[chunkKey] } @@ -447,39 +450,19 @@ export class WorldRendererThree extends WorldRendererCommon { return } - // remvoe object from scene - let object = this.sectionObjects[data.key] - if (object) { - this.scene.remove(object) - // disposeObject(object) - delete this.sectionObjects[data.key] - } - // Use ChunkMeshManager for optimized mesh handling - const mesh = this.chunkMeshManager.updateSection(data.key, data.geometry) + const sectionObject = this.chunkMeshManager.updateSection(data.key, data.geometry) - if (!mesh) { - console.warn(`Failed to get mesh for section ${data.key}`) + if (!sectionObject) { return } - // Create or update the section object container - object = new THREE.Group() - this.sectionObjects[data.key] = object - this.scene.add(object) - - // Add the pooled mesh to the container - object.add(mesh) - this.sectionObjects[data.key].mesh = mesh as THREE.Mesh - - // Handle signs and heads (these are added to the container, not the pooled mesh) - this.addSignsAndHeads(object as THREE.Group, data.geometry) this.updateBoxHelper(data.key) // Handle chunk-based rendering if (this.displayOptions.inWorldRenderingConfig._renderByChunks) { - object.visible = false + sectionObject.visible = false const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}` this.waitingChunksToDisplay[chunkKey] ??= [] this.waitingChunksToDisplay[chunkKey].push(data.key) @@ -489,62 +472,6 @@ export class WorldRendererThree extends WorldRendererCommon { } this.updatePosDataChunk(data.key) - object.matrixAutoUpdate = false - } - - private addSignsAndHeads (object: THREE.Group, geometry: MesherGeometryOutput) { - // Clear existing signs and heads - // const childrenToRemove = object.children.filter(child => child.name !== 'mesh' && child.name !== 'helper') - // for (const child of childrenToRemove) { - // object.remove(child) - // disposeObject(child) - // } - - // Add signs - if (Object.keys(geometry.signs).length) { - for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(geometry.signs)) { - const signBlockEntity = this.blockEntities[posKey] - if (!signBlockEntity) continue - const [x, y, z] = posKey.split(',') - const sign = this.renderSign(new Vec3(+x, +y, +z), rotation, isWall, isHanging, nbt.simplify(signBlockEntity)) - if (!sign) continue - object.add(sign) - } - } - - // Add heads - if (Object.keys(geometry.heads).length) { - for (const [posKey, { isWall, rotation }] of Object.entries(geometry.heads)) { - const headBlockEntity = this.blockEntities[posKey] - if (!headBlockEntity) continue - const [x, y, z] = posKey.split(',') - const head = this.renderHead(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(headBlockEntity)) - if (!head) continue - object.add(head) - } - } - } - - getSignTexture (position: Vec3, blockEntity, backSide = false) { - const chunk = chunkPos(position) - let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`) - if (!textures) { - textures = {} - this.chunkTextures.set(`${chunk[0]},${chunk[1]}`, textures) - } - const texturekey = `${position.x},${position.y},${position.z}` - // todo investigate bug and remove this so don't need to clean in section dirty - if (textures[texturekey]) return textures[texturekey] - - const PrismarineChat = PrismarineChatLoader(this.version) - const canvas = renderSign(blockEntity, PrismarineChat) - if (!canvas) return - const tex = new THREE.Texture(canvas) - tex.magFilter = THREE.NearestFilter - tex.minFilter = THREE.NearestFilter - tex.needsUpdate = true - textures[texturekey] = tex - return tex } getCameraPosition () { @@ -629,7 +556,7 @@ export class WorldRendererThree extends WorldRendererCommon { raycaster.far = distance // Limit raycast distance // Filter to only nearby chunks for performance - const nearbyChunks = Object.values(this.sectionObjects) + const nearbyChunks = Object.values(this.chunkMeshManager.sectionObjects) .filter(obj => obj.name === 'chunk' && obj.visible) .filter(obj => { // Get the mesh child which has the actual geometry @@ -787,7 +714,7 @@ export class WorldRendererThree extends WorldRendererCommon { chunksRenderBelowOverride !== undefined || chunksRenderDistanceOverride !== undefined ) { - for (const [key, object] of Object.entries(this.sectionObjects)) { + for (const [key, object] of Object.entries(this.chunkMeshManager.sectionObjects)) { const [x, y, z] = key.split(',').map(Number) const isVisible = // eslint-disable-next-line no-constant-binary-expression, sonarjs/no-redundant-boolean @@ -799,10 +726,6 @@ export class WorldRendererThree extends WorldRendererCommon { object.visible = isVisible } - } else { - for (const object of Object.values(this.sectionObjects)) { - object.visible = true - } } } @@ -842,9 +765,10 @@ export class WorldRendererThree extends WorldRendererCommon { } for (const fountain of this.fountains) { - if (this.sectionObjects[fountain.sectionId] && !this.sectionObjects[fountain.sectionId].foutain) { - fountain.createParticles(this.sectionObjects[fountain.sectionId]) - this.sectionObjects[fountain.sectionId].foutain = true + const sectionObject = this.chunkMeshManager.getSectionObject(fountain.sectionId) + if (sectionObject && !sectionObject.fountain) { + fountain.createParticles(sectionObject) + sectionObject.fountain = true } fountain.render() } @@ -854,87 +778,18 @@ export class WorldRendererThree extends WorldRendererCommon { } const end = performance.now() const totalTime = end - start + + if (this.worldRendererConfig.autoLowerRenderDistance) { + // Record render time for performance monitoring + this.chunkMeshManager.recordRenderTime(totalTime) + } + this.renderTimeAvgCount++ this.renderTimeAvg = ((this.renderTimeAvg * (this.renderTimeAvgCount - 1)) + totalTime) / this.renderTimeAvgCount this.renderTimeMax = Math.max(this.renderTimeMax, totalTime) this.currentRenderedFrames++ } - renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) { - const textures = blockEntity.SkullOwner?.Properties?.textures[0] - if (!textures) return - - try { - const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString()) - let skinUrl = textureData.textures?.SKIN?.url - const { skinTexturesProxy } = this.worldRendererConfig - if (skinTexturesProxy) { - skinUrl = skinUrl?.replace('http://textures.minecraft.net/', skinTexturesProxy) - .replace('https://textures.minecraft.net/', skinTexturesProxy) - } - - const mesh = getMesh(this, skinUrl, armorModel.head) - const group = new THREE.Group() - if (isWall) { - mesh.position.set(0, 0.3125, 0.3125) - } - // move head model down as armor have a different offset than blocks - mesh.position.y -= 23 / 16 - group.add(mesh) - group.position.set(position.x + 0.5, position.y + 0.045, position.z + 0.5) - group.rotation.set( - 0, - -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)), - 0 - ) - group.scale.set(0.8, 0.8, 0.8) - return group - } catch (err) { - console.error('Error decoding player texture:', err) - } - } - - renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { - const tex = this.getSignTexture(position, blockEntity) - - if (!tex) return - - // todo implement - // const key = JSON.stringify({ position, rotation, isWall }) - // if (this.signsCache.has(key)) { - // console.log('cached', key) - // } else { - // this.signsCache.set(key, tex) - // } - - const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: tex, transparent: true })) - mesh.renderOrder = 999 - - const lineHeight = 7 / 16 - const scaleFactor = isHanging ? 1.3 : 1 - mesh.scale.set(1 * scaleFactor, lineHeight * scaleFactor, 1 * scaleFactor) - - const thickness = (isHanging ? 2 : 1.5) / 16 - const wallSpacing = 0.25 / 16 - if (isWall && !isHanging) { - mesh.position.set(0, 0, -0.5 + thickness + wallSpacing + 0.0001) - } else { - mesh.position.set(0, 0, thickness / 2 + 0.0001) - } - - const group = new THREE.Group() - group.rotation.set( - 0, - -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)), - 0 - ) - group.add(mesh) - const height = (isHanging ? 10 : 8) / 16 - const heightOffset = (isHanging ? 0 : isWall ? 4.333 : 9.333) / 16 - const textPosition = height / 2 + heightOffset - group.position.set(position.x + 0.5, position.y + textPosition, position.z + 0.5) - return group - } lightUpdate (chunkX: number, chunkZ: number) { // set all sections in the chunk dirty @@ -944,45 +799,27 @@ export class WorldRendererThree extends WorldRendererCommon { } rerenderAllChunks () { // todo not clear what to do with loading chunks - for (const key of Object.keys(this.sectionObjects)) { + for (const key of Object.keys(this.chunkMeshManager.sectionObjects)) { const [x, y, z] = key.split(',').map(Number) this.setSectionDirty(new Vec3(x, y, z)) } } updateShowChunksBorder () { - for (const key of Object.keys(this.sectionObjects)) { + for (const key of Object.keys(this.chunkMeshManager.sectionObjects)) { this.updateBoxHelper(key) } } updateBoxHelper (key: string) { const { showChunkBorders } = this.worldRendererConfig - const section = this.sectionObjects[key] - if (!section) return - if (showChunkBorders) { - if (!section.boxHelper) { - // mesh with static dimensions: 16x16x16 - const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), this.chunkBoxMaterial) - staticChunkMesh.position.set(section.mesh!.position.x, section.mesh!.position.y, section.mesh!.position.z) - const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00) - boxHelper.name = 'helper' - // boxHelper.geometry.boundingSphere = section.mesh!.geometry.boundingSphere - section.add(boxHelper) - section.name = 'chunk' - section.boxHelper = boxHelper - } - - section.boxHelper.visible = true - } else if (section.boxHelper) { - section.boxHelper.visible = false - } + this.chunkMeshManager.updateBoxHelper(key, showChunkBorders, this.chunkBoxMaterial) } resetWorld () { super.resetWorld() - for (const mesh of Object.values(this.sectionObjects)) { + for (const mesh of Object.values(this.chunkMeshManager.sectionObjects)) { this.scene.remove(mesh) } @@ -999,7 +836,7 @@ export class WorldRendererThree extends WorldRendererCommon { getLoadedChunksRelative (pos: Vec3, includeY = false) { const [currentX, currentY, currentZ] = sectionPos(pos) - return Object.fromEntries(Object.entries(this.sectionObjects).map(([key, o]) => { + return Object.fromEntries(Object.entries(this.chunkMeshManager.sectionObjects).map(([key, o]) => { const [xRaw, yRaw, zRaw] = key.split(',').map(Number) const [x, y, z] = sectionPos({ x: xRaw, y: yRaw, z: zRaw }) const setKey = includeY ? `${x - currentX},${y - currentY},${z - currentZ}` : `${x - currentX},${z - currentZ}` @@ -1016,12 +853,13 @@ export class WorldRendererThree extends WorldRendererCommon { } readdChunks () { - for (const key of Object.keys(this.sectionObjects)) { - this.scene.remove(this.sectionObjects[key]) + const { sectionObjects } = this.chunkMeshManager + for (const key of Object.keys(sectionObjects)) { + this.scene.remove(sectionObjects[key]) } setTimeout(() => { - for (const key of Object.keys(this.sectionObjects)) { - this.scene.add(this.sectionObjects[key]) + for (const key of Object.keys(sectionObjects)) { + this.scene.add(sectionObjects[key]) } }, 500) } @@ -1049,15 +887,8 @@ export class WorldRendererThree extends WorldRendererCommon { // Remove instanced blocks for this section this.instancedRenderer?.removeSectionInstances(key) - // Release section from mesh pool + // Release section from mesh pool (this will also remove from scene) this.chunkMeshManager.releaseSection(key) - - const object = this.sectionObjects[key] - if (object) { - this.scene.remove(object) - disposeObject(object) - } - delete this.sectionObjects[key] } } @@ -1123,7 +954,7 @@ export class WorldRendererThree extends WorldRendererCommon { const chunkKey = `${chunkX},${chunkZ}` const sectionKey = `${chunkX},${sectionY},${chunkZ}` - return !!this.finishedChunks[chunkKey] || !!this.sectionObjects[sectionKey] + return !!this.finishedChunks[chunkKey] || !!this.chunkMeshManager.sectionObjects[sectionKey] } updateSectionOffsets () { @@ -1161,7 +992,7 @@ export class WorldRendererThree extends WorldRendererCommon { } // Apply the offset to the section object - const section = this.sectionObjects[key] + const section = this.chunkMeshManager.sectionObjects[key] if (section) { section.position.set( anim.currentOffsetX, diff --git a/src/controls.ts b/src/controls.ts index 9430f9c1..34876664 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -504,7 +504,9 @@ const alwaysPressedHandledCommand = (command: Command) => { lockUrl() } if (command === 'communication.toggleMicrophone') { - toggleMicrophoneMuted?.() + if (typeof toggleMicrophoneMuted === 'function') { + toggleMicrophoneMuted() + } } } diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index be5851b2..df2ff4fd 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -44,6 +44,7 @@ export const defaultOptions = { useVersionsTextures: 'latest', // Instanced rendering options useInstancedRendering: false, + autoLowerRenderDistance: false, forceInstancedOnly: false, instancedOnlyDistance: 6, enableSingleColorMode: false, diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 8d1e46b9..c530f831 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -116,6 +116,10 @@ export const watchOptionsAfterViewerInit = () => { // appViewer.inWorldRenderingConfig.neighborChunkUpdates = o.neighborChunkUpdates }) + watchValue(options, o => { + appViewer.inWorldRenderingConfig.autoLowerRenderDistance = o.autoLowerRenderDistance + }) + // Instanced rendering options watchValue(options, o => { appViewer.inWorldRenderingConfig.useInstancedRendering = o.useInstancedRendering From 96c5ebb379038bb75ce30179a6ab08e7349c13d3 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 20 Jul 2025 09:38:22 +0300 Subject: [PATCH 22/86] [to pick] fix chat crash --- src/react/Chat.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/Chat.tsx b/src/react/Chat.tsx index a45d7a69..20d77a10 100644 --- a/src/react/Chat.tsx +++ b/src/react/Chat.tsx @@ -45,7 +45,7 @@ const MessageLine = ({ message, currentPlayerName, chatOpened }: { message: Mess return
  • val).map(([name]) => name).join(' ')} data-time={message.timestamp ? new Date(message.timestamp).toLocaleString('en-US', { hour12: false }) : undefined}> {message.parts.map((msg, i) => { // Check if this is a text part that might contain a mention - if (msg.text && currentPlayerName) { + if (typeof msg.text === 'string' && currentPlayerName) { const parts = msg.text.split(new RegExp(`(@${currentPlayerName})`, 'i')) if (parts.length > 1) { return parts.map((txtPart, j) => { From 0921b40f882d15c7f7ef25edecee0def71a63c45 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 20 Jul 2025 09:38:49 +0300 Subject: [PATCH 23/86] fix signs --- renderer/viewer/three/worldrendererThree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 24d09f6f..31340304 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -385,7 +385,7 @@ export class WorldRendererThree extends WorldRendererCommon { // sum of distances: x + y + z const chunkDistance = Math.abs(x - this.cameraSectionPos.x) + Math.abs(y - this.cameraSectionPos.y) + Math.abs(z - this.cameraSectionPos.z) const sectionObject = this.chunkMeshManager.getSectionObject(key)! - sectionObject.renderOrder = 500 - chunkDistance + sectionObject.mesh!.renderOrder = 500 - chunkDistance } override updateViewerPosition (pos: Vec3): void { From be0993a00bc02ea7e6833538147cc74e73df876f Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 20 Jul 2025 10:02:21 +0300 Subject: [PATCH 24/86] up all --- renderer/viewer/lib/mesher/models.ts | 2 +- renderer/viewer/three/chunkMeshManager.ts | 8 ++++---- renderer/viewer/three/worldrendererThree.ts | 2 +- src/optionsGuiScheme.tsx | 18 ++++++++++-------- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index 6a01bf2e..d38bcbee 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -674,7 +674,7 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W } if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) { // Check if this block can use instanced rendering - if ((enableInstancedRendering && isBlockInstanceable(world, block)) || forceInstancedOnly) { + if ((enableInstancedRendering && isBlockInstanceable(world, block))/* || forceInstancedOnly */) { // Check if block should be culled (all faces hidden by neighbors) // TODO validate this if (shouldCullInstancedBlock(world, cursor, block)) { diff --git a/renderer/viewer/three/chunkMeshManager.ts b/renderer/viewer/three/chunkMeshManager.ts index a5ac58a6..ef2738dd 100644 --- a/renderer/viewer/three/chunkMeshManager.ts +++ b/renderer/viewer/three/chunkMeshManager.ts @@ -61,7 +61,7 @@ export class ChunkMeshManager { constructor ( public worldRenderer: WorldRendererThree, - public scene: THREE.Scene, + public scene: THREE.Group, public material: THREE.Material, public worldHeight: number, viewDistance = 3, @@ -674,7 +674,7 @@ class SignHeadsRenderer { } renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { - const tex = this.getSignTexture(position, blockEntity) + const tex = this.getSignTexture(position, blockEntity, isHanging) if (!tex) return @@ -715,7 +715,7 @@ class SignHeadsRenderer { return group } - getSignTexture (position: Vec3, blockEntity, backSide = false) { + getSignTexture (position: Vec3, blockEntity, isHanging, backSide = false) { const chunk = chunkPos(position) let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`) if (!textures) { @@ -727,7 +727,7 @@ class SignHeadsRenderer { if (textures[texturekey]) return textures[texturekey] const PrismarineChat = PrismarineChatLoader(this.worldRendererThree.version) - const canvas = renderSign(blockEntity, PrismarineChat) + const canvas = renderSign(blockEntity, isHanging, PrismarineChat) if (!canvas) return const tex = new THREE.Texture(canvas) tex.magFilter = THREE.NearestFilter diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index ceb48735..c066a1e8 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -102,7 +102,7 @@ export class WorldRendererThree extends WorldRendererCommon { this.cameraShake = new CameraShake(this, this.onRender) this.media = new ThreeJsMedia(this) this.instancedRenderer = new InstancedRenderer(this) - this.chunkMeshManager = new ChunkMeshManager(this, this.realScene, this.material, this.worldSizeParams.worldHeight, this.viewDistance) + this.chunkMeshManager = new ChunkMeshManager(this, this.scene, this.material, this.worldSizeParams.worldHeight, this.viewDistance) // Enable bypass pooling for debugging if URL param is present if (new URLSearchParams(location.search).get('bypassMeshPooling') === 'true') { diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 1beadb07..84cebb49 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -6,7 +6,7 @@ import { versionToNumber } from 'mc-assets/dist/utils' import { gameAdditionalState, miscUiState, openOptionsMenu, showModal } from './globalState' import { AppOptions, getChangedSettings, options, resetOptions } from './optionsStorage' import Button from './react/Button' -import { OptionMeta, OptionSlider } from './react/OptionsItems' +import { OptionButton, OptionMeta, OptionSlider } from './react/OptionsItems' import Slider from './react/Slider' import { getScreenRefreshRate } from './utils' import { setLoadingScreenStatus } from './appStatus' @@ -127,7 +127,14 @@ export const guiOptionsScheme: { } } - return + /> }, }, { From c93b7a6f8b71e6f45a7b46690181140970406225 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 20 Jul 2025 23:02:29 +0300 Subject: [PATCH 25/86] Refactor sign and head container addition in ChunkMeshManager with error handling --- renderer/viewer/three/chunkMeshManager.ts | 58 ++++++++++++----------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/renderer/viewer/three/chunkMeshManager.ts b/renderer/viewer/three/chunkMeshManager.ts index ef2738dd..0e32ed49 100644 --- a/renderer/viewer/three/chunkMeshManager.ts +++ b/renderer/viewer/three/chunkMeshManager.ts @@ -153,36 +153,40 @@ export class ChunkMeshManager { sectionObject.tilesCount = geometryData.positions.length / 3 / 4 sectionObject.blocksCount = geometryData.blocksCount - // Add signs container - if (Object.keys(geometryData.signs).length > 0) { - const signsContainer = new THREE.Group() - signsContainer.name = 'signs' - for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(geometryData.signs)) { - const signBlockEntity = this.worldRenderer.blockEntities[posKey] - if (!signBlockEntity) continue - const [x, y, z] = posKey.split(',') - const sign = this.signHeadsRenderer.renderSign(new Vec3(+x, +y, +z), rotation, isWall, isHanging, nbt.simplify(signBlockEntity)) - if (!sign) continue - signsContainer.add(sign) + try { + // Add signs container + if (Object.keys(geometryData.signs).length > 0) { + const signsContainer = new THREE.Group() + signsContainer.name = 'signs' + for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(geometryData.signs)) { + const signBlockEntity = this.worldRenderer.blockEntities[posKey] + if (!signBlockEntity) continue + const [x, y, z] = posKey.split(',') + const sign = this.signHeadsRenderer.renderSign(new Vec3(+x, +y, +z), rotation, isWall, isHanging, nbt.simplify(signBlockEntity)) + if (!sign) continue + signsContainer.add(sign) + } + sectionObject.add(signsContainer) + sectionObject.signsContainer = signsContainer } - sectionObject.add(signsContainer) - sectionObject.signsContainer = signsContainer - } - // Add heads container - if (Object.keys(geometryData.heads).length > 0) { - const headsContainer = new THREE.Group() - headsContainer.name = 'heads' - for (const [posKey, { isWall, rotation }] of Object.entries(geometryData.heads)) { - const headBlockEntity = this.worldRenderer.blockEntities[posKey] - if (!headBlockEntity) continue - const [x, y, z] = posKey.split(',') - const head = this.signHeadsRenderer.renderHead(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(headBlockEntity)) - if (!head) continue - headsContainer.add(head) + // Add heads container + if (Object.keys(geometryData.heads).length > 0) { + const headsContainer = new THREE.Group() + headsContainer.name = 'heads' + for (const [posKey, { isWall, rotation }] of Object.entries(geometryData.heads)) { + const headBlockEntity = this.worldRenderer.blockEntities[posKey] + if (!headBlockEntity) continue + const [x, y, z] = posKey.split(',') + const head = this.signHeadsRenderer.renderHead(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(headBlockEntity)) + if (!head) continue + headsContainer.add(head) + } + sectionObject.add(headsContainer) + sectionObject.headsContainer = headsContainer } - sectionObject.add(headsContainer) - sectionObject.headsContainer = headsContainer + } catch (err) { + console.error('ChunkMeshManager: Error adding signs or heads to section', err) } // Store and add to scene From c63b6bb5367abfe56f43db423e0d51ec2b9ac6fd Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 1 Aug 2025 03:35:45 +0300 Subject: [PATCH 26/86] [skip ci] test grid --- experiments/three.ts | 230 +++++++++++++++++++++++++++++++------------ 1 file changed, 166 insertions(+), 64 deletions(-) diff --git a/experiments/three.ts b/experiments/three.ts index 63e08d9e..cd82f5ec 100644 --- a/experiments/three.ts +++ b/experiments/three.ts @@ -1,6 +1,9 @@ import * as THREE from 'three' import globalTexture from 'mc-assets/dist/blocksAtlasLegacy.png' +// Import the renderBlockThree function +import { renderBlockThree } from '../renderer/viewer/lib/mesher/standaloneRenderer' + // Create scene, camera and renderer const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000) @@ -22,65 +25,107 @@ const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4) directionalLight.position.set(1, 1, 1) scene.add(directionalLight) +// Add grid helper for orientation +const gridHelper = new THREE.GridHelper(10, 10) +scene.add(gridHelper) + // Create shared material that will be used by all blocks const sharedMaterial = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, - alphaTest: 0.1 + alphaTest: 0.1, + // wireframe: true // Add wireframe for debugging }) -function createCustomGeometry(textureInfo: { u: number, v: number, su: number, sv: number }): THREE.BufferGeometry { - // Create custom geometry with specific UV coordinates for this block type - const geometry = new THREE.BoxGeometry(1, 1, 1) - - // Get UV attribute - const uvAttribute = geometry.getAttribute('uv') as THREE.BufferAttribute - const uvs = uvAttribute.array as Float32Array - - console.log('Original UVs:', Array.from(uvs)) - console.log('Texture info:', textureInfo) - - // BoxGeometry has 6 faces, each with 2 triangles (4 vertices), so 24 UV pairs total - // Apply the same texture to all faces for simplicity - for (let i = 0; i < uvs.length; i += 2) { - const u = uvs[i] - const v = uvs[i + 1] - - // Map from 0-1 to the specific texture region in the atlas - uvs[i] = textureInfo.u + u * textureInfo.su - uvs[i + 1] = textureInfo.v + v * textureInfo.sv - } - - console.log('Modified UVs:', Array.from(uvs)) - uvAttribute.needsUpdate = true - return geometry +// Create simple block models for testing +function createFullBlockModel(textureObj: any): any { + return [[{ + elements: [{ + from: [0, 0, 0], + to: [16, 16, 16], + faces: { + up: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + down: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + north: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + south: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + east: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + west: { + texture: textureObj, + uv: [0, 0, 16, 16] + } + } + }] + }]] } -let currentInstancedMesh: THREE.InstancedMesh | null = null -let currentRefCube: THREE.Mesh | null = null +function createHalfBlockModel(textureObj: any): any { + return [[{ + elements: [{ + from: [0, 0, 0], + to: [16, 8, 16], // Half height (8 instead of 16) + faces: { + up: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + down: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + north: { + texture: textureObj, + uv: [0, 0, 16, 8] // Half height UV + }, + south: { + texture: textureObj, + uv: [0, 0, 16, 8] // Half height UV + }, + east: { + texture: textureObj, + uv: [0, 0, 16, 8] // Half height UV + }, + west: { + texture: textureObj, + uv: [0, 0, 16, 8] // Half height UV + } + } + }] + }]] +} + +let currentFullBlockInstancedMesh: THREE.InstancedMesh | null = null +let currentHalfBlockInstancedMesh: THREE.InstancedMesh | null = null async function createInstancedBlock() { try { // Clean up previous meshes if they exist - if (currentInstancedMesh) { - scene.remove(currentInstancedMesh) - currentInstancedMesh.geometry.dispose() + if (currentFullBlockInstancedMesh) { + scene.remove(currentFullBlockInstancedMesh) + currentFullBlockInstancedMesh.geometry.dispose() } - if (currentRefCube) { - scene.remove(currentRefCube) - currentRefCube.geometry.dispose() + if (currentHalfBlockInstancedMesh) { + scene.remove(currentHalfBlockInstancedMesh) + currentHalfBlockInstancedMesh.geometry.dispose() } // Load the blocks atlas texture const textureLoader = new THREE.TextureLoader() - const texture = await new Promise((resolve, reject) => { - textureLoader.load( - globalTexture, - resolve, - undefined, - reject - ) - }) + const texture = await textureLoader.loadAsync(globalTexture) // Configure texture for pixel art texture.magFilter = THREE.NearestFilter @@ -101,7 +146,7 @@ async function createInstancedBlock() { const textureInfo = { u: 0 / atlasWidth, // Left edge (first column) - v: 2 * tileSize / atlasHeight, // Top edge (first row) + v: 2 * tileSize / atlasHeight, // Top edge (third row) su: tileSize / atlasWidth, // Width of one tile sv: tileSize / atlasHeight // Height of one tile } @@ -109,39 +154,96 @@ async function createInstancedBlock() { console.log('Atlas size:', atlasWidth, 'x', atlasHeight) console.log('Calculated texture info:', textureInfo) - // Create custom geometry with proper UV mapping - const geometry = createCustomGeometry(textureInfo) + // Create mock texture object that matches what the renderer expects + const mockTexture = { + u: textureInfo.u, + v: textureInfo.v, + su: textureInfo.su, + sv: textureInfo.sv, + debugName: 'test_texture' + } - // Create instanced mesh using shared material - currentInstancedMesh = new THREE.InstancedMesh(geometry, sharedMaterial, 1) + // Create block models with the mock texture + const fullBlockModel = createFullBlockModel(mockTexture) + const halfBlockModel = createHalfBlockModel(mockTexture) + + // Mock data for the renderBlockThree function + const mockBlock = undefined // No specific block data needed for this test + const mockBiome = 'plains' + const mockMcData = {} as any + const mockVariants = [] + const mockNeighbors = {} + + // Render the full block + const fullBlockGeometry = renderBlockThree( + fullBlockModel, + mockBlock, + mockBiome, + mockMcData, + mockVariants, + mockNeighbors + ) + + // Render the half block + const halfBlockGeometry = renderBlockThree( + halfBlockModel, + mockBlock, + mockBiome, + mockMcData, + mockVariants, + mockNeighbors + ) + + // Create instanced mesh for full blocks + currentFullBlockInstancedMesh = new THREE.InstancedMesh(fullBlockGeometry, sharedMaterial, 2) // Support 2 instances const matrix = new THREE.Matrix4() - matrix.setPosition(0.5, 0.5, 0.5) // Offset by +0.5 on each axis - currentInstancedMesh.setMatrixAt(0, matrix) - currentInstancedMesh.count = 1 - currentInstancedMesh.instanceMatrix.needsUpdate = true - scene.add(currentInstancedMesh) - // Reference non-instanced cube using same material - currentRefCube = new THREE.Mesh(geometry, sharedMaterial) - currentRefCube.position.set(2.5, 0.5, 0.5) // Offset by +0.5 on each axis - scene.add(currentRefCube) + // First instance (full block) + matrix.setPosition(-1.5, 0.5, 0.5) + currentFullBlockInstancedMesh.setMatrixAt(0, matrix) - console.log('Instanced block created successfully') + // Second instance (full block) + matrix.setPosition(1.5, 0.5, 0.5) + currentFullBlockInstancedMesh.setMatrixAt(1, matrix) + + currentFullBlockInstancedMesh.count = 2 + currentFullBlockInstancedMesh.instanceMatrix.needsUpdate = true + scene.add(currentFullBlockInstancedMesh) + + // Create instanced mesh for half blocks + currentHalfBlockInstancedMesh = new THREE.InstancedMesh(halfBlockGeometry, sharedMaterial, 1) // Support 1 instance + const halfMatrix = new THREE.Matrix4() + + // Half block instance + halfMatrix.setPosition(0, 0.75, 0.5) // Positioned higher so top aligns with full blocks + currentHalfBlockInstancedMesh.setMatrixAt(0, halfMatrix) + + currentHalfBlockInstancedMesh.count = 1 + currentHalfBlockInstancedMesh.instanceMatrix.needsUpdate = true + scene.add(currentHalfBlockInstancedMesh) + + console.log('Instanced blocks created successfully') + console.log('Full block geometry:', fullBlockGeometry) + console.log('Half block geometry:', halfBlockGeometry) } catch (error) { - console.error('Error creating instanced block:', error) + console.error('Error creating instanced blocks:', error) - // Fallback: create a colored cube + // Fallback: create colored cubes const geometry = new THREE.BoxGeometry(1, 1, 1) - const material = new THREE.MeshLambertMaterial({ color: 0xff0000 }) - currentRefCube = new THREE.Mesh(geometry, material) - scene.add(currentRefCube) + const material = new THREE.MeshLambertMaterial({ color: 0xff0000, wireframe: true }) + const fallbackMesh = new THREE.Mesh(geometry, material) + fallbackMesh.position.set(0, 0.5, 0.5) + scene.add(fallbackMesh) + console.log('Created fallback colored cube') } } // Create the instanced block -createInstancedBlock() +createInstancedBlock().then(() => { + render() +}) // Simple render loop (no animation) function render() { @@ -165,7 +267,7 @@ renderer.domElement.addEventListener('mousemove', (event) => { const deltaX = event.clientX - mouseX const deltaY = event.clientY - mouseY - // Rotate camera around the cube + // Rotate camera around the center const spherical = new THREE.Spherical() spherical.setFromVector3(camera.position) spherical.theta -= deltaX * 0.01 From c3aad71024a7d8bd724bf25f0ad2b780432505cc Mon Sep 17 00:00:00 2001 From: Vitaly Date: Wed, 6 Aug 2025 18:02:24 +0200 Subject: [PATCH 27/86] [deploy] send token channel --- src/customChannels.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/customChannels.ts b/src/customChannels.ts index 57c057d5..882c46d0 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -15,6 +15,28 @@ export default () => { registerMediaChannels() registerSectionAnimationChannels() registeredJeiChannel() + sendTokenChannel() + }) +} + +const sendTokenChannel = () => { + const CHANNEL_NAME = 'minecraft-web-client:token' + bot._client.registerChannel(CHANNEL_NAME, [ + 'container', + [ + { + name: 'token', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'userId', + type: ['pstring', { countType: 'i16' }] + }, + ] + ], true) + bot._client.writeChannel(CHANNEL_NAME, { + token: new URLSearchParams(window.location.search).get('token') ?? '', + userId: new URLSearchParams(window.location.search).get('userId') ?? '' }) } From 167b49da08bf66f2617188f6d905e5efee9da42f Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 8 Aug 2025 01:07:52 +0200 Subject: [PATCH 28/86] fix: fix cannot write after stream was destroyed message (#413) --- README.MD | 2 +- patches/minecraft-protocol.patch | 77 +++++++++----------------------- pnpm-lock.yaml | 16 +++---- src/index.ts | 8 +++- 4 files changed, 36 insertions(+), 67 deletions(-) diff --git a/README.MD b/README.MD index e9127a73..7978cee5 100644 --- a/README.MD +++ b/README.MD @@ -54,7 +54,7 @@ Howerver, it's known that these browsers have issues: ### Versions Support -Server versions 1.8 - 1.21.4 are supported. +Server versions 1.8 - 1.21.5 are supported. First class versions (most of the features are tested on these versions): - 1.19.4 diff --git a/patches/minecraft-protocol.patch b/patches/minecraft-protocol.patch index e74f7e1d..5dec44d7 100644 --- a/patches/minecraft-protocol.patch +++ b/patches/minecraft-protocol.patch @@ -1,8 +1,8 @@ diff --git a/src/client/chat.js b/src/client/chat.js -index a50f4b988ad9fb29d5eb9e1633b498615aa9cd28..b8b819eef0762a77d9db5c0fb06be648303628c7 100644 +index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 100644 --- a/src/client/chat.js +++ b/src/client/chat.js -@@ -110,7 +110,7 @@ module.exports = function (client, options) { +@@ -116,7 +116,7 @@ module.exports = function (client, options) { for (const player of packet.data) { if (player.chatSession) { client._players[player.uuid] = { @@ -11,8 +11,8 @@ index a50f4b988ad9fb29d5eb9e1633b498615aa9cd28..b8b819eef0762a77d9db5c0fb06be648 publicKeyDER: player.chatSession.publicKey.keyBytes, sessionUuid: player.chatSession.uuid } -@@ -120,7 +120,7 @@ module.exports = function (client, options) { - +@@ -126,7 +126,7 @@ module.exports = function (client, options) { + if (player.crypto) { client._players[player.uuid] = { - publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }), @@ -20,7 +20,7 @@ index a50f4b988ad9fb29d5eb9e1633b498615aa9cd28..b8b819eef0762a77d9db5c0fb06be648 publicKeyDER: player.crypto.publicKey, signature: player.crypto.signature, displayName: player.displayName || player.name -@@ -190,7 +190,7 @@ module.exports = function (client, options) { +@@ -196,7 +196,7 @@ module.exports = function (client, options) { if (mcData.supportFeature('useChatSessions')) { const tsDelta = BigInt(Date.now()) - packet.timestamp const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0 @@ -29,16 +29,16 @@ index a50f4b988ad9fb29d5eb9e1633b498615aa9cd28..b8b819eef0762a77d9db5c0fb06be648 if (verified) client._signatureCache.push(packet.signature) client.emit('playerChat', { globalIndex: packet.globalIndex, -@@ -356,7 +356,7 @@ module.exports = function (client, options) { +@@ -362,7 +362,7 @@ module.exports = function (client, options) { } } - + - client._signedChat = (message, options = {}) => { + client._signedChat = async (message, options = {}) => { options.timestamp = options.timestamp || BigInt(Date.now()) options.salt = options.salt || 1n - -@@ -401,7 +401,7 @@ module.exports = function (client, options) { + +@@ -407,7 +407,7 @@ module.exports = function (client, options) { message, timestamp: options.timestamp, salt: options.salt, @@ -47,7 +47,7 @@ index a50f4b988ad9fb29d5eb9e1633b498615aa9cd28..b8b819eef0762a77d9db5c0fb06be648 offset: client._lastSeenMessages.pending, checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+ acknowledged -@@ -416,7 +416,7 @@ module.exports = function (client, options) { +@@ -422,7 +422,7 @@ module.exports = function (client, options) { message, timestamp: options.timestamp, salt: options.salt, @@ -71,10 +71,10 @@ index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f8 + // clearTimeout(loginTimeout) + // }) } - + function onJoinServerResponse (err) { diff --git a/src/client.js b/src/client.js -index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..11c6bff299f1186ab1ecb6744f53ff0c648ab192 100644 +index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875989cdf0c 100644 --- a/src/client.js +++ b/src/client.js @@ -111,7 +111,13 @@ class Client extends EventEmitter { @@ -94,7 +94,7 @@ index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..11c6bff299f1186ab1ecb6744f53ff0c } @@ -169,7 +175,10 @@ class Client extends EventEmitter { } - + const onFatalError = (err) => { - this.emit('error', err) + // todo find out what is trying to write after client disconnect @@ -103,58 +103,23 @@ index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..11c6bff299f1186ab1ecb6744f53ff0c + } endSocket() } - -@@ -198,6 +207,8 @@ class Client extends EventEmitter { + +@@ -198,6 +207,10 @@ class Client extends EventEmitter { serializer -> framer -> socket -> splitter -> deserializer */ if (this.serializer) { this.serializer.end() -+ this.socket?.end() -+ this.socket?.emit('end') ++ setTimeout(() => { ++ this.socket?.end() ++ this.socket?.emit('end') ++ }, 2000) // allow the serializer to finish writing } else { if (this.socket) this.socket.end() } -@@ -243,6 +254,7 @@ class Client extends EventEmitter { +@@ -243,6 +256,7 @@ class Client extends EventEmitter { debug('writing packet ' + this.state + '.' + name) debug(params) } + this.emit('writePacket', name, params) this.serializer.write({ name, params }) } - -diff --git a/src/client.js.rej b/src/client.js.rej -new file mode 100644 -index 0000000000000000000000000000000000000000..1101e2477adfdc004381b78e7d70953dacb7b484 ---- /dev/null -+++ b/src/client.js.rej -@@ -0,0 +1,31 @@ -+@@ -89,10 +89,12 @@ -+ parsed.metadata.name = parsed.data.name -+ parsed.data = parsed.data.params -+ parsed.metadata.state = state -+- debug('read packet ' + state + '.' + parsed.metadata.name) -+- if (debug.enabled) { -+- const s = JSON.stringify(parsed.data, null, 2) -+- debug(s && s.length > 10000 ? parsed.data : s) -++ if (!globalThis.excludeCommunicationDebugEvents?.includes(parsed.metadata.name)) { -++ debug('read packet ' + state + '.' + parsed.metadata.name) -++ if (debug.enabled) { -++ const s = JSON.stringify(parsed.data, null, 2) -++ debug(s && s.length > 10000 ? parsed.data : s) -++ } -+ } -+ if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') { -+ if (this._mcBundle.length) { // End bundle -+@@ -239,8 +252,11 @@ -+ -+ write (name, params) { -+ if (!this.serializer.writable) { return } -+- debug('writing packet ' + this.state + '.' + name) -+- debug(params) -++ if (!globalThis.excludeCommunicationDebugEvents?.includes(name)) { -++ debug(`[${this.state}] from ${this.isServer ? 'server' : 'client'}: ` + name) -++ debug(params) -++ } -++ this.emit('writePacket', name, params) -+ this.serializer.write({ name, params }) -+ } -+ + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db095f5b..132ef32c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ overrides: patchedDependencies: minecraft-protocol: - hash: b417b3b7c5fd96e59abab5c1075b86b88bada2c980e4b54df13ca69b8f0091d9 + hash: 2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea path: patches/minecraft-protocol.patch mineflayer-item-map-downloader@1.2.0: hash: a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad @@ -142,7 +142,7 @@ importers: version: 3.92.0 minecraft-protocol: specifier: github:PrismarineJS/node-minecraft-protocol#master - version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=b417b3b7c5fd96e59abab5c1075b86b88bada2c980e4b54df13ca69b8f0091d9)(encoding@0.1.13) + version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) mineflayer-item-map-downloader: specifier: github:zardoy/mineflayer-item-map-downloader version: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13) @@ -13078,7 +13078,7 @@ snapshots: flatmap: 0.0.3 long: 5.3.1 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=b417b3b7c5fd96e59abab5c1075b86b88bada2c980e4b54df13ca69b8f0091d9)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -13114,7 +13114,7 @@ snapshots: flatmap: 0.0.3 long: 5.3.1 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=b417b3b7c5fd96e59abab5c1075b86b88bada2c980e4b54df13ca69b8f0091d9)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -16961,7 +16961,7 @@ snapshots: dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=b417b3b7c5fd96e59abab5c1075b86b88bada2c980e4b54df13ca69b8f0091d9)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/c9c77d6511e37c452ebe48790724da165d6ad448(encoding@0.1.13) prismarine-item: 1.17.0 ws: 8.18.1 @@ -17280,7 +17280,7 @@ snapshots: - '@types/react' - react - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=b417b3b7c5fd96e59abab5c1075b86b88bada2c980e4b54df13ca69b8f0091d9)(encoding@0.1.13): + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13): dependencies: '@types/node-rsa': 1.1.4 '@types/readable-stream': 4.0.18 @@ -17360,7 +17360,7 @@ snapshots: mineflayer@4.31.0(encoding@0.1.13): dependencies: minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=b417b3b7c5fd96e59abab5c1075b86b88bada2c980e4b54df13ca69b8f0091d9)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chat: 1.11.0 @@ -17384,7 +17384,7 @@ snapshots: dependencies: '@nxg-org/mineflayer-physics-util': 1.8.10 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=b417b3b7c5fd96e59abab5c1075b86b88bada2c980e4b54df13ca69b8f0091d9)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chat: 1.11.0 diff --git a/src/index.ts b/src/index.ts index d28261f4..c0213f56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -231,8 +231,12 @@ export async function connect (connectOptions: ConnectOptions) { bot.emit('end', '') bot.removeAllListeners() bot._client.removeAllListeners() - //@ts-expect-error TODO? - bot._client = undefined + bot._client = { + //@ts-expect-error + write (packetName) { + console.warn('Tried to write packet', packetName, 'after bot was destroyed') + } + } //@ts-expect-error window.bot = bot = undefined } From caf4695637f1e1b86a98db90f12021df00822ca7 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 8 Aug 2025 18:33:20 +0300 Subject: [PATCH 29/86] feat: silly player on fire renderer effect --- renderer/viewer/lib/basePlayerState.ts | 1 + src/entities.ts | 63 ++++++++++ src/react/FireRenderer.tsx | 152 +++++++++++++++++++++++++ src/reactUi.tsx | 2 + 4 files changed, 218 insertions(+) create mode 100644 src/react/FireRenderer.tsx diff --git a/renderer/viewer/lib/basePlayerState.ts b/renderer/viewer/lib/basePlayerState.ts index af5d9d06..9cf1350a 100644 --- a/renderer/viewer/lib/basePlayerState.ts +++ b/renderer/viewer/lib/basePlayerState.ts @@ -48,6 +48,7 @@ export const getInitialPlayerState = () => proxy({ heldItemMain: undefined as HandItemBlock | undefined, heldItemOff: undefined as HandItemBlock | undefined, perspective: 'first_person' as CameraPerspective, + onFire: false, cameraSpectatingEntity: undefined as number | undefined, diff --git a/src/entities.ts b/src/entities.ts index 79602aa5..cf91ff2c 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -126,6 +126,28 @@ customEvents.on('gameLoaded', () => { if (entityStatus === EntityStatus.HURT) { getThreeJsRendererMethods()?.damageEntity(entityId, entityStatus) } + + if (entityStatus === EntityStatus.BURNED) { + updateEntityStates(entityId, true, true) + } + }) + + // on fire events + bot._client.on('entity_metadata', (data) => { + if (data.entityId !== bot.entity.id) return + handleEntityMetadata(data) + }) + + bot.on('end', () => { + if (onFireTimeout) { + clearTimeout(onFireTimeout) + } + }) + + bot.on('respawn', () => { + if (onFireTimeout) { + clearTimeout(onFireTimeout) + } }) const updateCamera = (entity: Entity) => { @@ -296,3 +318,44 @@ customEvents.on('gameLoaded', () => { }) }) + +// Constants +const SHARED_FLAGS_KEY = 0 +const ENTITY_FLAGS = { + ON_FIRE: 0x01, // Bit 0 + SNEAKING: 0x02, // Bit 1 + SPRINTING: 0x08, // Bit 3 + SWIMMING: 0x10, // Bit 4 + INVISIBLE: 0x20, // Bit 5 + GLOWING: 0x40, // Bit 6 + FALL_FLYING: 0x80 // Bit 7 (elytra flying) +} + +let onFireTimeout: NodeJS.Timeout | undefined +const updateEntityStates = (entityId: number, onFire: boolean, timeout?: boolean) => { + if (entityId !== bot.entity.id) return + appViewer.playerState.reactive.onFire = onFire + if (onFireTimeout) { + clearTimeout(onFireTimeout) + } + if (timeout) { + onFireTimeout = setTimeout(() => { + updateEntityStates(entityId, false, false) + }, 5000) + } +} + +// Process entity metadata packet +function handleEntityMetadata (packet: { entityId: number, metadata: Array<{ key: number, type: string, value: number }> }) { + const { entityId, metadata } = packet + + // Find shared flags in metadata + const flagsData = metadata.find(meta => meta.key === SHARED_FLAGS_KEY && + meta.type === 'byte') + + // Update fire state if flags were found + if (flagsData) { + const wasOnFire = appViewer.playerState.reactive.onFire + appViewer.playerState.reactive.onFire = (flagsData.value & ENTITY_FLAGS.ON_FIRE) !== 0 + } +} diff --git a/src/react/FireRenderer.tsx b/src/react/FireRenderer.tsx new file mode 100644 index 00000000..3a188558 --- /dev/null +++ b/src/react/FireRenderer.tsx @@ -0,0 +1,152 @@ +/* eslint-disable no-await-in-loop */ +import { useSnapshot } from 'valtio' +import { useEffect, useState } from 'react' +import { getLoadedImage } from 'mc-assets/dist/utils' +import { createCanvas } from 'renderer/viewer/lib/utils' + +const TEXTURE_UPDATE_INTERVAL = 100 // 5 times per second + +export default () => { + const { onFire, perspective } = useSnapshot(appViewer.playerState.reactive) + const [fireTextures, setFireTextures] = useState([]) + const [currentTextureIndex, setCurrentTextureIndex] = useState(0) + + useEffect(() => { + let animationFrameId: number + let lastTextureUpdate = 0 + + const updateTexture = (timestamp: number) => { + if (onFire && fireTextures.length > 0) { + if (timestamp - lastTextureUpdate >= TEXTURE_UPDATE_INTERVAL) { + setCurrentTextureIndex(prev => (prev + 1) % fireTextures.length) + lastTextureUpdate = timestamp + } + } + animationFrameId = requestAnimationFrame(updateTexture) + } + + animationFrameId = requestAnimationFrame(updateTexture) + return () => cancelAnimationFrame(animationFrameId) + }, [onFire, fireTextures]) + + useEffect(() => { + const loadTextures = async () => { + const fireImageUrls: string[] = [] + + const { resourcesManager } = appViewer + const { blocksAtlasParser } = resourcesManager + if (!blocksAtlasParser?.atlas?.latest) { + console.warn('FireRenderer: Blocks atlas parser not available') + return + } + + const keys = Object.keys(blocksAtlasParser.atlas.latest.textures).filter(key => /^fire_\d+$/.exec(key)) + for (const key of keys) { + const textureInfo = blocksAtlasParser.getTextureInfo(key) as { u: number, v: number, width?: number, height?: number } + if (textureInfo) { + const defaultSize = blocksAtlasParser.atlas.latest.tileSize + const imageWidth = blocksAtlasParser.atlas.latest.width + const imageHeight = blocksAtlasParser.atlas.latest.height + const textureWidth = textureInfo.width ?? defaultSize + const textureHeight = textureInfo.height ?? defaultSize + + // Create a temporary canvas for the full texture + const tempCanvas = createCanvas(textureWidth, textureHeight) + const tempCtx = tempCanvas.getContext('2d') + if (tempCtx && blocksAtlasParser.latestImage) { + const image = await getLoadedImage(blocksAtlasParser.latestImage) + tempCtx.drawImage( + image, + textureInfo.u * imageWidth, + textureInfo.v * imageHeight, + textureWidth, + textureHeight, + 0, + 0, + textureWidth, + textureHeight + ) + + // Create final canvas with only top 20% of the texture + const finalHeight = Math.ceil(textureHeight * 0.4) + const canvas = createCanvas(textureWidth, finalHeight) + const ctx = canvas.getContext('2d') + if (ctx) { + // Draw only the top portion + ctx.drawImage( + tempCanvas, + 0, + 0, // Start from top + textureWidth, + finalHeight, + 0, + 0, + textureWidth, + finalHeight + ) + + const blob = await canvas.convertToBlob() + const url = URL.createObjectURL(blob) + fireImageUrls.push(url) + } + } + } + } + + setFireTextures(fireImageUrls) + } + + // Load textures initially + if (appViewer.resourcesManager.currentResources) { + void loadTextures() + } + + // Set up listener for texture updates + const onAssetsUpdated = () => { + void loadTextures() + } + appViewer.resourcesManager.on('assetsTexturesUpdated', onAssetsUpdated) + + // Cleanup + return () => { + appViewer.resourcesManager.off('assetsTexturesUpdated', onAssetsUpdated) + // Cleanup texture URLs + for (const url of fireTextures) URL.revokeObjectURL(url) + } + }, []) + + if (!onFire || fireTextures.length === 0 || perspective !== 'first_person') return null + + return ( +
    +
    +
    + ) +} diff --git a/src/reactUi.tsx b/src/reactUi.tsx index b15cb79d..4f8c4541 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -66,6 +66,7 @@ import CreditsAboutModal from './react/CreditsAboutModal' import GlobalOverlayHints from './react/GlobalOverlayHints' import FullscreenTime from './react/FullscreenTime' import StorageConflictModal from './react/StorageConflictModal' +import FireRenderer from './react/FireRenderer' const isFirefox = ua.getBrowser().name === 'Firefox' if (isFirefox) { @@ -171,6 +172,7 @@ const InGameUi = () => { + {!disabledUiParts.includes('fire') && }
    From 53cbff7699d5255974a09e5f1e50b02759116a63 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 8 Aug 2025 18:37:10 +0300 Subject: [PATCH 30/86] dont conflict fire with chat --- src/react/FireRenderer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/react/FireRenderer.tsx b/src/react/FireRenderer.tsx index 3a188558..20ad4606 100644 --- a/src/react/FireRenderer.tsx +++ b/src/react/FireRenderer.tsx @@ -130,7 +130,8 @@ export default () => { display: 'flex', justifyContent: 'center', alignItems: 'flex-end', - overflow: 'hidden' + overflow: 'hidden', + zIndex: -1 }} >
    Date: Fri, 8 Aug 2025 21:52:55 +0300 Subject: [PATCH 31/86] fix: some blocks textures were not update in hotbar after texturepack change --- renderer/viewer/three/renderSlot.ts | 20 +++++++++++++------- src/inventoryWindows.ts | 1 + src/react/HotbarRenderApp.tsx | 3 ++- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/renderer/viewer/three/renderSlot.ts b/renderer/viewer/three/renderSlot.ts index d82e58e3..321633eb 100644 --- a/renderer/viewer/three/renderSlot.ts +++ b/renderer/viewer/three/renderSlot.ts @@ -10,11 +10,11 @@ export type ResolvedItemModelRender = { export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: ResourcesManagerCommon, debugIsQuickbar = false, fullBlockModelSupport = false): { texture: string, - blockData?: Record & { resolvedModel: BlockModel }, - scale?: number, - slice?: number[], - modelName?: string, -} | undefined => { + blockData: Record & { resolvedModel: BlockModel } | null, + scale: number | null, + slice: number[] | null, + modelName: string | null, +} => { let itemModelName = model.modelName const isItem = loadedData.itemsByName[itemModelName] @@ -37,6 +37,8 @@ export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: Res texture: 'gui', slice: [x, y, atlas.tileSize, atlas.tileSize], scale: 0.25, + blockData: null, + modelName: null } } } @@ -63,14 +65,18 @@ export const renderSlot = (model: ResolvedItemModelRender, resourcesManager: Res return { texture: itemTexture.type, slice: itemTexture.slice, - modelName: itemModelName + modelName: itemModelName, + blockData: null, + scale: null } } else { // is block return { texture: 'blocks', blockData: itemTexture, - modelName: itemModelName + modelName: itemModelName, + slice: null, + scale: null } } } diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index bc7dcbaf..a9f89d1b 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -259,6 +259,7 @@ export const upInventoryItems = (isInventory: boolean, invWindow = lastWindow) = // inv.pwindow.inv.slots[2].blockData = getBlockData('dirt') const customSlots = mapSlots((isInventory ? bot.inventory : bot.currentWindow)!.slots) invWindow.pwindow.setSlots(customSlots) + return customSlots } export const onModalClose = (callback: () => any) => { diff --git a/src/react/HotbarRenderApp.tsx b/src/react/HotbarRenderApp.tsx index 6b6e3207..c782e6ef 100644 --- a/src/react/HotbarRenderApp.tsx +++ b/src/react/HotbarRenderApp.tsx @@ -115,7 +115,7 @@ const HotbarInner = () => { container.current.appendChild(inv.canvas) const upHotbarItems = () => { if (!appViewer.resourcesManager?.itemsAtlasParser) return - upInventoryItems(true, inv) + globalThis.debugHotbarItems = upInventoryItems(true, inv) } canvasManager.canvas.onclick = (e) => { @@ -127,6 +127,7 @@ const HotbarInner = () => { } } + globalThis.debugUpHotbarItems = upHotbarItems upHotbarItems() bot.inventory.on('updateSlot', upHotbarItems) appViewer.resourcesManager.on('assetsTexturesUpdated', upHotbarItems) From fb395041b9e40449819e5a8d0361f06264bfa80b Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 11 Aug 2025 01:39:08 +0300 Subject: [PATCH 32/86] fix: fix on 1.18.2 many blocks like mushrom blocks, fence gates, deepslate, basalt, copper stuff like ore, infested stone, cakes and tinted glass was resulting in instant breaking on the client dev: add debugTestPing --- scripts/makeOptimizedMcData.mjs | 7 +++-- src/mineflayer/mc-protocol.ts | 49 ++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/scripts/makeOptimizedMcData.mjs b/scripts/makeOptimizedMcData.mjs index 0b5752d8..76e0f1c2 100644 --- a/scripts/makeOptimizedMcData.mjs +++ b/scripts/makeOptimizedMcData.mjs @@ -90,16 +90,19 @@ const dataTypeBundling = { }, blocks: { arrKey: 'name', - processData(current, prev) { + processData(current, prev, _, version) { for (const block of current) { + const prevBlock = prev?.find(x => x.name === block.name) if (block.transparent) { const forceOpaque = block.name.includes('shulker_box') || block.name.match(/^double_.+_slab\d?$/) || ['melon_block', 'lit_pumpkin', 'lit_redstone_ore', 'lit_furnace'].includes(block.name) - const prevBlock = prev?.find(x => x.name === block.name) if (forceOpaque || (prevBlock && !prevBlock.transparent)) { block.transparent = false } } + if (block.hardness === 0 && prevBlock && prevBlock.hardness > 0) { + block.hardness = prevBlock.hardness + } } } // ignoreRemoved: true, diff --git a/src/mineflayer/mc-protocol.ts b/src/mineflayer/mc-protocol.ts index 2376cd03..a0348c5d 100644 --- a/src/mineflayer/mc-protocol.ts +++ b/src/mineflayer/mc-protocol.ts @@ -1,8 +1,11 @@ +import net from 'net' import { Client } from 'minecraft-protocol' import { appQueryParams } from '../appParams' import { downloadAllMinecraftData, getVersionAutoSelect } from '../connect' import { gameAdditionalState } from '../globalState' import { ProgressReporter } from '../core/progressReporter' +import { parseServerAddress } from '../parseServerAddress' +import { getCurrentProxy } from '../react/ServersList' import { pingServerVersion, validatePacket } from './minecraft-protocol-extra' import { getWebsocketStream } from './websocket-core' @@ -35,7 +38,7 @@ setInterval(() => { }, 1000) -export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false, progressReporter?: ProgressReporter) => { +export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false, progressReporter?: ProgressReporter, setProxyParams?: ProxyParams) => { await downloadAllMinecraftData() const isWebSocket = ip.startsWith('ws://') || ip.startsWith('wss://') let stream @@ -43,6 +46,8 @@ export const getServerInfo = async (ip: string, port?: number, preferredVersion progressReporter?.setMessage('Connecting to WebSocket server') stream = (await getWebsocketStream(ip)).mineflayerStream progressReporter?.setMessage('WebSocket connected. Ping packet sent, waiting for response') + } else if (setProxyParams) { + setProxy(setProxyParams) } window.setLoadingMessage = (message?: string) => { if (message === undefined) { @@ -59,3 +64,45 @@ export const getServerInfo = async (ip: string, port?: number, preferredVersion window.setLoadingMessage = undefined }) } + +globalThis.debugTestPing = async (ip: string) => { + const parsed = parseServerAddress(ip, false) + const result = await getServerInfo(parsed.host, parsed.port ? Number(parsed.port) : undefined, undefined, true, undefined, { address: getCurrentProxy(), }) + console.log('result', result) + return result +} + +export const getDefaultProxyParams = () => { + return { + headers: { + Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` + } + } +} + +export type ProxyParams = { + address?: string + headers?: Record +} + +export const setProxy = (proxyParams: ProxyParams) => { + if (proxyParams.address?.startsWith(':')) { + proxyParams.address = `${location.protocol}//${location.hostname}${proxyParams.address}` + } + if (proxyParams.address && location.port !== '80' && location.port !== '443' && !/:\d+$/.test(proxyParams.address)) { + const https = proxyParams.address.startsWith('https://') || location.protocol === 'https:' + proxyParams.address = `${proxyParams.address}:${https ? 443 : 80}` + } + + const parsedProxy = parseServerAddress(proxyParams.address, false) + const proxy = { host: parsedProxy.host, port: parsedProxy.port } + proxyParams.headers ??= getDefaultProxyParams().headers + net['setProxy']({ + hostname: proxy.host, + port: proxy.port, + headers: proxyParams.headers + }) + return { + proxy + } +} From e7c358d3fc0497b66fa25c70a401d2aed4be3294 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 11 Aug 2025 03:12:05 +0300 Subject: [PATCH 33/86] feat: add `minecraft-web-client:block-interactions-customization` --- package.json | 2 +- pnpm-lock.yaml | 20 ++++++++++---------- src/customChannels.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 257fff7c..b719b877 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "mc-assets": "^0.2.62", "minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next", "mineflayer": "github:zardoy/mineflayer#gen-the-master", - "mineflayer-mouse": "^0.1.14", + "mineflayer-mouse": "^0.1.17", "mineflayer-pathfinder": "^2.4.4", "npm-run-all": "^4.1.5", "os-browserify": "^0.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 132ef32c..da2b3912 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -343,8 +343,8 @@ importers: specifier: github:zardoy/mineflayer#gen-the-master version: https://codeload.github.com/zardoy/mineflayer/tar.gz/c9c77d6511e37c452ebe48790724da165d6ad448(encoding@0.1.13) mineflayer-mouse: - specifier: ^0.1.14 - version: 0.1.14 + specifier: ^0.1.17 + version: 0.1.17 mineflayer-pathfinder: specifier: ^2.4.4 version: 2.4.5 @@ -6667,8 +6667,8 @@ packages: resolution: {tarball: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824} version: 1.2.0 - mineflayer-mouse@0.1.14: - resolution: {integrity: sha512-DjytRMlRLxR44GqZ6udMgbMO4At7Ura5TQC80exRhzkfptyCGLTWzXaf0oeXSNYkNMnaaEv4XP/9YRwuvL+rsQ==} + mineflayer-mouse@0.1.17: + resolution: {integrity: sha512-0eCR8pnGb42Qd9QmAxOjl0PhA5Fa+9+6H1G/YsbsO5rg5mDf94Tusqp/8NAGLPQCPVDzbarLskXdjR3h0E0bEQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} mineflayer-pathfinder@2.4.5: @@ -10294,7 +10294,7 @@ snapshots: '@babel/parser': 7.26.9 '@babel/template': 7.26.9 '@babel/types': 7.26.9 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -12864,7 +12864,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 6.1.0(typescript@5.5.4) '@typescript-eslint/utils': 6.1.0(eslint@8.57.1)(typescript@5.5.4) - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 eslint: 8.57.1 ts-api-utils: 1.4.3(typescript@5.5.4) optionalDependencies: @@ -12896,7 +12896,7 @@ snapshots: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -12911,7 +12911,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.26.0 '@typescript-eslint/visitor-keys': 8.26.0 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -17338,7 +17338,7 @@ snapshots: - encoding - supports-color - mineflayer-mouse@0.1.14: + mineflayer-mouse@0.1.17: dependencies: change-case: 5.4.4 debug: 4.4.1 @@ -18457,7 +18457,7 @@ snapshots: puppeteer-core@2.1.1: dependencies: '@types/mime-types': 2.1.4 - debug: 4.4.0(supports-color@8.1.1) + debug: 4.4.1 extract-zip: 1.7.0 https-proxy-agent: 4.0.0 mime: 2.6.0 diff --git a/src/customChannels.ts b/src/customChannels.ts index 57c057d5..3f6a8217 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -15,6 +15,7 @@ export default () => { registerMediaChannels() registerSectionAnimationChannels() registeredJeiChannel() + registerBlockInteractionsCustomizationChannel() }) } @@ -32,6 +33,36 @@ const registerChannel = (channelName: string, packetStructure: any[], handler: ( console.debug(`registered custom channel ${channelName} channel`) } +const registerBlockInteractionsCustomizationChannel = () => { + const CHANNEL_NAME = 'minecraft-web-client:block-interactions-customization' + const packetStructure = [ + 'container', + [ + { + name: 'newConfiguration', + type: ['pstring', { countType: 'i16' }] + }, + ] + ] + + registerChannel(CHANNEL_NAME, packetStructure, (data) => { + const config = JSON.parse(data.newConfiguration) + if (config.customBreakTime !== undefined && Object.values(config.customBreakTime).every(x => typeof x === 'number')) { + bot.mouse.customBreakTime = config.customBreakTime + } + if (config.customBreakTimeToolAllowance !== undefined) { + bot.mouse.customBreakTimeToolAllowance = new Set(config.customBreakTimeToolAllowance) + } + + if (config.blockPlacePrediction !== undefined) { + bot.mouse.settings.blockPlacePrediction = config.blockPlacePrediction + } + if (config.blockPlacePredictionDelay !== undefined) { + bot.mouse.settings.blockPlacePredictionDelay = config.blockPlacePredictionDelay + } + }, true) +} + const registerBlockModelsChannel = () => { const CHANNEL_NAME = 'minecraft-web-client:blockmodels' From cdd8c31a0e9261ee57fb66ff8ca5af0e074bff78 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 11 Aug 2025 21:21:44 +0300 Subject: [PATCH 34/86] fix: fix player colored username rendering, fix sometimes skin was overriden --- renderer/viewer/three/entities.ts | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/renderer/viewer/three/entities.ts b/renderer/viewer/three/entities.ts index 6c6f8900..24f64803 100644 --- a/renderer/viewer/three/entities.ts +++ b/renderer/viewer/three/entities.ts @@ -141,7 +141,7 @@ const addNametag = (entity, options: { fontFamily: string }, mesh, version: stri const canvas = getUsernameTexture(entity, options, version) const tex = new THREE.Texture(canvas) tex.needsUpdate = true - let nameTag + let nameTag: THREE.Object3D if (entity.nameTagFixed) { const geometry = new THREE.PlaneGeometry() const material = new THREE.MeshBasicMaterial({ map: tex }) @@ -171,6 +171,7 @@ const addNametag = (entity, options: { fontFamily: string }, mesh, version: stri nameTag.name = 'nametag' mesh.add(nameTag) + return nameTag } } @@ -494,6 +495,10 @@ export class Entities { // todo true/undefined doesnt reset the skin to the default one // eslint-disable-next-line max-params async updatePlayerSkin (entityId: string | number, username: string | undefined, uuidCache: string | undefined, skinUrl: string | true, capeUrl: string | true | undefined = undefined) { + const isCustomSkin = skinUrl !== stevePngUrl + if (isCustomSkin) { + this.loadedSkinEntityIds.add(String(entityId)) + } if (uuidCache) { if (typeof skinUrl === 'string' || typeof capeUrl === 'string') this.uuidPerSkinUrlsCache[uuidCache] = {} if (typeof skinUrl === 'string') this.uuidPerSkinUrlsCache[uuidCache].skinUrl = skinUrl @@ -912,20 +917,14 @@ export class Entities { mesh = wrapper if (entity.username) { - // todo proper colors - const nameTag = new NameTagObject(fromFormattedString(entity.username).text, { - font: `48px ${this.entitiesOptions.fontFamily}`, - }) - nameTag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3 - nameTag.renderOrder = 1000 - - nameTag.name = 'nametag' - - //@ts-expect-error - wrapper.add(nameTag) + const nametag = addNametag(entity, { fontFamily: 'mojangles' }, wrapper, this.worldRenderer.version) + if (nametag) { + nametag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3 + nametag.scale.multiplyScalar(12) + } } } else { - mesh = getEntityMesh(entity, this.worldRenderer, this.entitiesOptions, overrides) + mesh = getEntityMesh(entity, this.worldRenderer, this.entitiesOptions, { ...overrides, customModel: entity['customModel'] }) } if (!mesh) return mesh.name = 'mesh' @@ -1181,8 +1180,7 @@ export class Entities { const cameraPos = this.worldRenderer.cameraObject.position const distance = mesh.position.distanceTo(cameraPos) if (distance < MAX_DISTANCE_SKIN_LOAD && distance < (this.worldRenderer.viewDistance * 16)) { - if (this.loadedSkinEntityIds.has(entityId)) return - this.loadedSkinEntityIds.add(entityId) + if (this.loadedSkinEntityIds.has(String(entityId))) return void this.updatePlayerSkin(entityId, mesh.playerObject.realUsername, mesh.playerObject.realPlayerUuid, true, true) } } From 0a474e67803827cdd657b135ba6a7380091cdab4 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 12 Aug 2025 06:27:06 +0300 Subject: [PATCH 35/86] feat: add custom experimental waypints impl --- experiments/three-labels.html | 5 + experiments/three-labels.ts | 67 ++++ renderer/viewer/three/graphicsBackend.ts | 3 + renderer/viewer/three/waypointSprite.ts | 394 ++++++++++++++++++++ renderer/viewer/three/waypoints.ts | 142 +++++++ renderer/viewer/three/worldrendererThree.ts | 25 +- src/customChannels.ts | 57 +++ 7 files changed, 685 insertions(+), 8 deletions(-) create mode 100644 experiments/three-labels.html create mode 100644 experiments/three-labels.ts create mode 100644 renderer/viewer/three/waypointSprite.ts create mode 100644 renderer/viewer/three/waypoints.ts diff --git a/experiments/three-labels.html b/experiments/three-labels.html new file mode 100644 index 00000000..2b25bc23 --- /dev/null +++ b/experiments/three-labels.html @@ -0,0 +1,5 @@ + + diff --git a/experiments/three-labels.ts b/experiments/three-labels.ts new file mode 100644 index 00000000..b69dc95b --- /dev/null +++ b/experiments/three-labels.ts @@ -0,0 +1,67 @@ +import * as THREE from 'three' +import { FirstPersonControls } from 'three/addons/controls/FirstPersonControls.js' +import { createWaypointSprite, WAYPOINT_CONFIG } from '../renderer/viewer/three/waypointSprite' + +// Create scene, camera and renderer +const scene = new THREE.Scene() +const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000) +const renderer = new THREE.WebGLRenderer({ antialias: true }) +renderer.setSize(window.innerWidth, window.innerHeight) +document.body.appendChild(renderer.domElement) + +// Add FirstPersonControls +const controls = new FirstPersonControls(camera, renderer.domElement) +controls.lookSpeed = 0.1 +controls.movementSpeed = 10 +controls.lookVertical = true +controls.constrainVertical = true +controls.verticalMin = 0.1 +controls.verticalMax = Math.PI - 0.1 + +// Position camera +camera.position.y = 1.6 // Typical eye height +camera.lookAt(0, 1.6, -1) + +// Create a helper grid and axes +const grid = new THREE.GridHelper(20, 20) +scene.add(grid) +const axes = new THREE.AxesHelper(5) +scene.add(axes) + +// Create waypoint sprite via utility +const waypoint = createWaypointSprite({ + position: new THREE.Vector3(0, 0, -5), + color: 0xff0000, + label: 'Target', +}) +scene.add(waypoint.group) + +// Use built-in offscreen arrow from utils +waypoint.enableOffscreenArrow(true) +waypoint.setArrowParent(scene) + +// Animation loop +function animate() { + requestAnimationFrame(animate) + + const delta = Math.min(clock.getDelta(), 0.1) + controls.update(delta) + + // Unified camera update (size, distance text, arrow, visibility) + const sizeVec = renderer.getSize(new THREE.Vector2()) + waypoint.updateForCamera(camera.position, camera, sizeVec.width, sizeVec.height) + + renderer.render(scene, camera) +} + +// Handle window resize +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight + camera.updateProjectionMatrix() + renderer.setSize(window.innerWidth, window.innerHeight) +}) + +// Add clock for controls +const clock = new THREE.Clock() + +animate() diff --git a/renderer/viewer/three/graphicsBackend.ts b/renderer/viewer/three/graphicsBackend.ts index 5ea89b34..92b6ec66 100644 --- a/renderer/viewer/three/graphicsBackend.ts +++ b/renderer/viewer/three/graphicsBackend.ts @@ -44,6 +44,9 @@ const getBackendMethods = (worldRenderer: WorldRendererThree) => { shakeFromDamage: worldRenderer.cameraShake.shakeFromDamage.bind(worldRenderer.cameraShake), onPageInteraction: worldRenderer.media.onPageInteraction.bind(worldRenderer.media), downloadMesherLog: worldRenderer.downloadMesherLog.bind(worldRenderer), + + addWaypoint: worldRenderer.waypoints.addWaypoint.bind(worldRenderer.waypoints), + removeWaypoint: worldRenderer.waypoints.removeWaypoint.bind(worldRenderer.waypoints), } } diff --git a/renderer/viewer/three/waypointSprite.ts b/renderer/viewer/three/waypointSprite.ts new file mode 100644 index 00000000..7c8cf1f6 --- /dev/null +++ b/renderer/viewer/three/waypointSprite.ts @@ -0,0 +1,394 @@ +import * as THREE from 'three' + +// Centralized visual configuration (in screen pixels) +export const WAYPOINT_CONFIG = { + // Target size in screen pixels (this controls the final sprite size) + TARGET_SCREEN_PX: 150, + // Canvas size for internal rendering (keep power of 2 for textures) + CANVAS_SIZE: 256, + // Relative positions in canvas (0-1) + LAYOUT: { + DOT_Y: 0.3, + NAME_Y: 0.45, + DISTANCE_Y: 0.55, + }, + // Multiplier for canvas internal resolution to keep text crisp + CANVAS_SCALE: 2, + ARROW: { + enabledDefault: false, + pixelSize: 30, + paddingPx: 50, + }, +} + +export type WaypointSprite = { + group: THREE.Group + sprite: THREE.Sprite + // Offscreen arrow controls + enableOffscreenArrow: (enabled: boolean) => void + setArrowParent: (parent: THREE.Object3D | null) => void + // Convenience combined updater + updateForCamera: ( + cameraPosition: THREE.Vector3, + camera: THREE.PerspectiveCamera, + viewportWidthPx: number, + viewportHeightPx: number + ) => boolean + // Utilities + setColor: (color: number) => void + setLabel: (label?: string) => void + updateDistanceText: (label: string, distanceText: string) => void + setVisible: (visible: boolean) => void + setPosition: (x: number, y: number, z: number) => void + dispose: () => void +} + +export function createWaypointSprite (options: { + position: THREE.Vector3 | { x: number, y: number, z: number }, + color?: number, + label?: string, + depthTest?: boolean, + // Y offset in world units used by updateScaleWorld only (screen-pixel API ignores this) + labelYOffset?: number, +}): WaypointSprite { + const color = options.color ?? 0xFF_00_00 + const depthTest = options.depthTest ?? false + const labelYOffset = options.labelYOffset ?? 1.5 + + // Build combined sprite + const sprite = createCombinedSprite(color, options.label ?? '', '0m', depthTest) + sprite.renderOrder = 10 + let currentLabel = options.label ?? '' + + // Offscreen arrow (detached by default) + let arrowSprite: THREE.Sprite | undefined + let arrowParent: THREE.Object3D | null = null + let arrowEnabled = WAYPOINT_CONFIG.ARROW.enabledDefault + + // Group for easy add/remove + const group = new THREE.Group() + group.add(sprite) + + // Initial position + const { x, y, z } = options.position + group.position.set(x, y, z) + + function setColor (newColor: number) { + const canvas = drawCombinedCanvas(newColor, currentLabel, '0m') + const texture = new THREE.CanvasTexture(canvas) + const mat = sprite.material + mat.map?.dispose() + mat.map = texture + mat.needsUpdate = true + } + + function setLabel (newLabel?: string) { + currentLabel = newLabel ?? '' + const canvas = drawCombinedCanvas(color, currentLabel, '0m') + const texture = new THREE.CanvasTexture(canvas) + const mat = sprite.material + mat.map?.dispose() + mat.map = texture + mat.needsUpdate = true + } + + function updateDistanceText (label: string, distanceText: string) { + const canvas = drawCombinedCanvas(color, label, distanceText) + const texture = new THREE.CanvasTexture(canvas) + const mat = sprite.material + mat.map?.dispose() + mat.map = texture + mat.needsUpdate = true + } + + function setVisible (visible: boolean) { + sprite.visible = visible + } + + function setPosition (nx: number, ny: number, nz: number) { + group.position.set(nx, ny, nz) + } + + // Keep constant pixel size on screen using global config + function updateScaleScreenPixels ( + cameraPosition: THREE.Vector3, + cameraFov: number, + distance: number, + viewportHeightPx: number + ) { + const vFovRad = cameraFov * Math.PI / 180 + const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * distance + // Use configured target screen size + const scale = worldUnitsPerScreenHeightAtDist * (WAYPOINT_CONFIG.TARGET_SCREEN_PX / viewportHeightPx) + sprite.scale.set(scale, scale, 1) + } + + function ensureArrow () { + if (arrowSprite) return + const size = 128 + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d')! + ctx.clearRect(0, 0, size, size) + ctx.beginPath() + ctx.moveTo(size * 0.2, size * 0.5) + ctx.lineTo(size * 0.8, size * 0.5) + ctx.lineTo(size * 0.5, size * 0.2) + ctx.closePath() + ctx.lineWidth = 4 + ctx.strokeStyle = 'black' + ctx.stroke() + ctx.fillStyle = 'white' + ctx.fill() + const texture = new THREE.CanvasTexture(canvas) + const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false }) + arrowSprite = new THREE.Sprite(material) + arrowSprite.renderOrder = 12 + arrowSprite.visible = false + if (arrowParent) arrowParent.add(arrowSprite) + } + + function enableOffscreenArrow (enabled: boolean) { + arrowEnabled = enabled + if (!enabled && arrowSprite) arrowSprite.visible = false + } + + function setArrowParent (parent: THREE.Object3D | null) { + if (arrowSprite?.parent) arrowSprite.parent.remove(arrowSprite) + arrowParent = parent + if (arrowSprite && parent) parent.add(arrowSprite) + } + + function updateOffscreenArrow ( + camera: THREE.PerspectiveCamera, + viewportWidthPx: number, + viewportHeightPx: number + ): boolean { + if (!arrowEnabled) return true + ensureArrow() + if (!arrowSprite) return true + + // Build camera basis using camera.up to respect custom orientations + const forward = new THREE.Vector3() + camera.getWorldDirection(forward) // camera look direction + const upWorld = camera.up.clone().normalize() + const right = new THREE.Vector3().copy(forward).cross(upWorld).normalize() + const upCam = new THREE.Vector3().copy(right).cross(forward).normalize() + + // Vector from camera to waypoint + const camPos = new THREE.Vector3().setFromMatrixPosition(camera.matrixWorld) + const toWp = new THREE.Vector3(group.position.x, group.position.y, group.position.z).sub(camPos) + + // Components in camera basis + const z = toWp.dot(forward) + const x = toWp.dot(right) + const y = toWp.dot(upCam) + + const aspect = viewportWidthPx / viewportHeightPx + const vFovRad = camera.fov * Math.PI / 180 + const hFovRad = 2 * Math.atan(Math.tan(vFovRad / 2) * aspect) + + // Determine if waypoint is inside view frustum using angular checks + const thetaX = Math.atan2(x, z) + const thetaY = Math.atan2(y, z) + const visible = z > 0 && Math.abs(thetaX) <= hFovRad / 2 && Math.abs(thetaY) <= vFovRad / 2 + if (visible) { + arrowSprite.visible = false + return true + } + + // Direction on screen in normalized frustum units + let rx = thetaX / (hFovRad / 2) + let ry = thetaY / (vFovRad / 2) + + // If behind the camera, snap to dominant axis to avoid confusing directions + if (z <= 0) { + if (Math.abs(rx) > Math.abs(ry)) { + rx = Math.sign(rx) + ry = 0 + } else { + rx = 0 + ry = Math.sign(ry) + } + } + + // Place on the rectangle border [-1,1]x[-1,1] + const s = Math.max(Math.abs(rx), Math.abs(ry)) || 1 + let ndcX = rx / s + let ndcY = ry / s + + // Apply padding in pixel space by clamping + const padding = WAYPOINT_CONFIG.ARROW.paddingPx + const pxX = ((ndcX + 1) * 0.5) * viewportWidthPx + const pxY = ((1 - ndcY) * 0.5) * viewportHeightPx + const clampedPxX = Math.min(Math.max(pxX, padding), viewportWidthPx - padding) + const clampedPxY = Math.min(Math.max(pxY, padding), viewportHeightPx - padding) + ndcX = (clampedPxX / viewportWidthPx) * 2 - 1 + ndcY = -(clampedPxY / viewportHeightPx) * 2 + 1 + + // Compute world position at a fixed distance in front of the camera using camera basis + const placeDist = Math.max(2, camera.near * 4) + const halfPlaneHeight = Math.tan(vFovRad / 2) * placeDist + const halfPlaneWidth = halfPlaneHeight * aspect + const pos = camPos.clone() + .add(forward.clone().multiplyScalar(placeDist)) + .add(right.clone().multiplyScalar(ndcX * halfPlaneWidth)) + .add(upCam.clone().multiplyScalar(ndcY * halfPlaneHeight)) + + // Update arrow sprite + arrowSprite.visible = true + arrowSprite.position.copy(pos) + + // Angle for rotation relative to screen right/up (derived from camera up vector) + const angle = Math.atan2(ry, rx) + arrowSprite.material.rotation = angle - Math.PI / 2 + + // Constant pixel size for arrow (use fixed placement distance) + const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * placeDist + const sPx = worldUnitsPerScreenHeightAtDist * (WAYPOINT_CONFIG.ARROW.pixelSize / viewportHeightPx) + arrowSprite.scale.set(sPx, sPx, 1) + return false + } + + function computeDistance (cameraPosition: THREE.Vector3): number { + return cameraPosition.distanceTo(group.position) + } + + function updateForCamera ( + cameraPosition: THREE.Vector3, + camera: THREE.PerspectiveCamera, + viewportWidthPx: number, + viewportHeightPx: number + ): boolean { + const distance = computeDistance(cameraPosition) + // Keep constant pixel size + updateScaleScreenPixels(cameraPosition, camera.fov, distance, viewportHeightPx) + // Update text + updateDistanceText(currentLabel, `${Math.round(distance)}m`) + // Update arrow and visibility + const onScreen = updateOffscreenArrow(camera, viewportWidthPx, viewportHeightPx) + setVisible(onScreen) + return onScreen + } + + function dispose () { + const mat = sprite.material + mat.map?.dispose() + mat.dispose() + if (arrowSprite) { + const am = arrowSprite.material + am.map?.dispose() + am.dispose() + } + } + + return { + group, + sprite, + enableOffscreenArrow, + setArrowParent, + updateForCamera, + setColor, + setLabel, + updateDistanceText, + setVisible, + setPosition, + dispose, + } +} + +// Internal helpers +function drawCombinedCanvas (color: number, id: string, distance: string): HTMLCanvasElement { + const scale = WAYPOINT_CONFIG.CANVAS_SCALE * (globalThis.devicePixelRatio || 1) + const size = WAYPOINT_CONFIG.CANVAS_SIZE * scale + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d')! + + // Clear canvas + ctx.clearRect(0, 0, size, size) + + // Draw dot + const centerX = size / 2 + const dotY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.DOT_Y) + const radius = Math.round(size * 0.05) // Dot takes up ~12% of canvas height + const borderWidth = Math.max(2, Math.round(4 * scale)) + + // Outer border (black) + ctx.beginPath() + ctx.arc(centerX, dotY, radius + borderWidth, 0, Math.PI * 2) + ctx.fillStyle = 'black' + ctx.fill() + + // Inner circle (colored) + ctx.beginPath() + ctx.arc(centerX, dotY, radius, 0, Math.PI * 2) + ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}` + ctx.fill() + + // Text properties + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + // Title + const nameFontPx = Math.round(size * 0.08) // ~8% of canvas height + const distanceFontPx = Math.round(size * 0.06) // ~6% of canvas height + ctx.font = `bold ${nameFontPx}px mojangles` + ctx.lineWidth = Math.max(2, Math.round(3 * scale)) + const nameY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.NAME_Y) + + ctx.strokeStyle = 'black' + ctx.strokeText(id, centerX, nameY) + ctx.fillStyle = 'white' + ctx.fillText(id, centerX, nameY) + + // Distance + ctx.font = `bold ${distanceFontPx}px mojangles` + ctx.lineWidth = Math.max(2, Math.round(2 * scale)) + const distanceY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.DISTANCE_Y) + + ctx.strokeStyle = 'black' + ctx.strokeText(distance, centerX, distanceY) + ctx.fillStyle = '#CCCCCC' + ctx.fillText(distance, centerX, distanceY) + + return canvas +} + +function createCombinedSprite (color: number, id: string, distance: string, depthTest: boolean): THREE.Sprite { + const canvas = drawCombinedCanvas(color, id, distance) + const texture = new THREE.CanvasTexture(canvas) + texture.anisotropy = 1 + texture.magFilter = THREE.LinearFilter + texture.minFilter = THREE.LinearFilter + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 1, + depthTest, + depthWrite: false, + }) + const sprite = new THREE.Sprite(material) + sprite.position.set(0, 0, 0) + return sprite +} + +export const WaypointHelpers = { + // World-scale constant size helper + computeWorldScale (distance: number, fixedReference = 10) { + return Math.max(0.0001, distance / fixedReference) + }, + // Screen-pixel constant size helper + computeScreenPixelScale ( + camera: THREE.PerspectiveCamera, + distance: number, + pixelSize: number, + viewportHeightPx: number + ) { + const vFovRad = camera.fov * Math.PI / 180 + const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * distance + return worldUnitsPerScreenHeightAtDist * (pixelSize / viewportHeightPx) + } +} diff --git a/renderer/viewer/three/waypoints.ts b/renderer/viewer/three/waypoints.ts new file mode 100644 index 00000000..c7ec9a93 --- /dev/null +++ b/renderer/viewer/three/waypoints.ts @@ -0,0 +1,142 @@ +import * as THREE from 'three' +import { WorldRendererThree } from './worldrendererThree' +import { createWaypointSprite, type WaypointSprite } from './waypointSprite' + +interface Waypoint { + id: string + x: number + y: number + z: number + minDistance: number + color: number + label?: string + sprite: WaypointSprite +} + +interface WaypointOptions { + color?: number + label?: string + minDistance?: number +} + +export class WaypointsRenderer { + private readonly waypoints = new Map() + private readonly waypointScene = new THREE.Scene() + private readonly FIXED_REFERENCE = 10 // Fixed reference distance for scaling + + constructor ( + private readonly worldRenderer: WorldRendererThree + ) { + if (process.env.NODE_ENV !== 'production') { + this.addWaypoint('spawn', 0, 0, 0, { }) + } + } + + private updateWaypoints () { + const playerPos = this.worldRenderer.cameraObject.position + const sizeVec = this.worldRenderer.renderer.getSize(new THREE.Vector2()) + + for (const waypoint of this.waypoints.values()) { + const waypointPos = new THREE.Vector3(waypoint.x, waypoint.y, waypoint.z) + const distance = playerPos.distanceTo(waypointPos) + const visible = !waypoint.minDistance || distance >= waypoint.minDistance + + waypoint.sprite.setVisible(visible) + + if (visible) { + // Update position + waypoint.sprite.setPosition(waypoint.x, waypoint.y, waypoint.z) + // Ensure camera-based update each frame + waypoint.sprite.updateForCamera(this.worldRenderer.getCameraPosition(), this.worldRenderer.camera, sizeVec.width, sizeVec.height) + } + } + } + + render () { + if (this.waypoints.size === 0) return + + // Update waypoint scaling + this.updateWaypoints() + + // Render waypoints scene with the world camera + this.worldRenderer.renderer.render(this.waypointScene, this.worldRenderer.camera) + } + + // Removed sprite/label texture creation. Use utils/waypointSprite.ts + + addWaypoint ( + id: string, + x: number, + y: number, + z: number, + options: WaypointOptions = {} + ) { + // Remove existing waypoint if it exists + this.removeWaypoint(id) + + const color = options.color ?? 0xFF_00_00 + const { label } = options + const minDistance = options.minDistance ?? 0 + + const sprite = createWaypointSprite({ + position: new THREE.Vector3(x, y, z), + color, + label: (label || id), + }) + sprite.enableOffscreenArrow(true) + sprite.setArrowParent(this.waypointScene) + + this.waypointScene.add(sprite.group) + + this.waypoints.set(id, { + id, x, y, z, minDistance, + color, label, + sprite, + }) + } + + removeWaypoint (id: string) { + const waypoint = this.waypoints.get(id) + if (waypoint) { + this.waypointScene.remove(waypoint.sprite.group) + waypoint.sprite.dispose() + this.waypoints.delete(id) + } + } + + clear () { + for (const id of this.waypoints.keys()) { + this.removeWaypoint(id) + } + } + + testWaypoint () { + this.addWaypoint('Test Point', 0, 70, 0, { color: 0x00_FF_00, label: 'Test Point' }) + this.addWaypoint('Spawn', 0, 64, 0, { color: 0xFF_FF_00, label: 'Spawn' }) + this.addWaypoint('Far Point', 100, 70, 100, { color: 0x00_00_FF, label: 'Far Point' }) + } + + getWaypoint (id: string): Waypoint | undefined { + return this.waypoints.get(id) + } + + getAllWaypoints (): Waypoint[] { + return [...this.waypoints.values()] + } + + setWaypointColor (id: string, color: number) { + const waypoint = this.waypoints.get(id) + if (waypoint) { + waypoint.sprite.setColor(color) + waypoint.color = color + } + } + + setWaypointLabel (id: string, label?: string) { + const waypoint = this.waypoints.get(id) + if (waypoint) { + waypoint.label = label + waypoint.sprite.setLabel(label) + } + } +} diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index bc95f06b..82856cb9 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -23,6 +23,7 @@ import { ThreeJsSound } from './threeJsSound' import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' import { Fountain } from './threeJsParticles' +import { WaypointsRenderer } from './waypoints' type SectionKey = string @@ -48,6 +49,7 @@ export class WorldRendererThree extends WorldRendererCommon { cameraContainer: THREE.Object3D media: ThreeJsMedia waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] } + waypoints: WaypointsRenderer camera: THREE.PerspectiveCamera renderTimeAvg = 0 sectionsOffsetsAnimations = {} as { @@ -99,6 +101,8 @@ export class WorldRendererThree extends WorldRendererCommon { this.soundSystem = new ThreeJsSound(this) this.cameraShake = new CameraShake(this, this.onRender) this.media = new ThreeJsMedia(this) + this.waypoints = new WaypointsRenderer(this) + // this.fountain = new Fountain(this.scene, this.scene, { // position: new THREE.Vector3(0, 10, 0), // }) @@ -119,6 +123,8 @@ export class WorldRendererThree extends WorldRendererCommon { this.protocolCustomBlocks.clear() // Reset section animations this.sectionsOffsetsAnimations = {} + // Clear waypoints + this.waypoints.clear() }) } @@ -453,7 +459,7 @@ export class WorldRendererThree extends WorldRendererCommon { return worldPos } - getWorldCameraPosition () { + getSectionCameraPosition () { const pos = this.getCameraPosition() return new Vec3( Math.floor(pos.x / 16), @@ -463,7 +469,7 @@ export class WorldRendererThree extends WorldRendererCommon { } updateCameraSectionPos () { - const newSectionPos = this.getWorldCameraPosition() + const newSectionPos = this.getSectionCameraPosition() if (!this.cameraSectionPos.equals(newSectionPos)) { this.cameraSectionPos = newSectionPos this.cameraSectionPositionUpdate() @@ -737,6 +743,8 @@ export class WorldRendererThree extends WorldRendererCommon { fountain.render() } + this.waypoints.render() + for (const onRender of this.onRender) { onRender() } @@ -1022,6 +1030,13 @@ class StarField { constructor ( private readonly worldRenderer: WorldRendererThree ) { + const clock = new THREE.Clock() + const speed = 0.2 + this.worldRenderer.onRender.push(() => { + if (!this.points) return + this.points.position.copy(this.worldRenderer.getCameraPosition()); + (this.points.material as StarfieldMaterial).uniforms.time.value = clock.getElapsedTime() * speed + }) } addToScene () { @@ -1032,7 +1047,6 @@ class StarField { const count = 7000 const factor = 7 const saturation = 10 - const speed = 0.2 const geometry = new THREE.BufferGeometry() @@ -1065,11 +1079,6 @@ class StarField { this.points = new THREE.Points(geometry, material) this.worldRenderer.scene.add(this.points) - const clock = new THREE.Clock() - this.points.onBeforeRender = (renderer, scene, camera) => { - this.points?.position.copy?.(this.worldRenderer.getCameraPosition()) - material.uniforms.time.value = clock.getElapsedTime() * speed - } this.points.renderOrder = -1 } diff --git a/src/customChannels.ts b/src/customChannels.ts index 3f6a8217..6d3aa7e9 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -16,6 +16,7 @@ export default () => { registerSectionAnimationChannels() registeredJeiChannel() registerBlockInteractionsCustomizationChannel() + registerWaypointChannels() }) } @@ -63,6 +64,62 @@ const registerBlockInteractionsCustomizationChannel = () => { }, true) } +const registerWaypointChannels = () => { + const packetStructure = [ + 'container', + [ + { + name: 'id', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'x', + type: 'f32' + }, + { + name: 'y', + type: 'f32' + }, + { + name: 'z', + type: 'f32' + }, + { + name: 'minDistance', + type: 'i32' + }, + { + name: 'label', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'color', + type: 'i32' + } + ] + ] + + registerChannel('minecraft-web-client:waypoint-add', packetStructure, (data) => { + getThreeJsRendererMethods()?.addWaypoint(data.id, data.x, data.y, data.z, { + minDistance: data.minDistance, + label: data.label || undefined, + color: data.color || undefined + }) + }) + + registerChannel('minecraft-web-client:waypoint-delete', [ + 'container', + [ + { + name: 'id', + type: ['pstring', { countType: 'i16' }] + } + ] + ], (data) => { + getThreeJsRendererMethods()?.removeWaypoint(data.id) + }) +} + const registerBlockModelsChannel = () => { const CHANNEL_NAME = 'minecraft-web-client:blockmodels' From 8827aab981d27d95b4863156a35fd66ba7b810c2 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 12 Aug 2025 06:27:42 +0300 Subject: [PATCH 36/86] dont add test waypoints on dev --- renderer/viewer/three/waypoints.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/renderer/viewer/three/waypoints.ts b/renderer/viewer/three/waypoints.ts index c7ec9a93..45937ee8 100644 --- a/renderer/viewer/three/waypoints.ts +++ b/renderer/viewer/three/waypoints.ts @@ -22,14 +22,10 @@ interface WaypointOptions { export class WaypointsRenderer { private readonly waypoints = new Map() private readonly waypointScene = new THREE.Scene() - private readonly FIXED_REFERENCE = 10 // Fixed reference distance for scaling constructor ( private readonly worldRenderer: WorldRendererThree ) { - if (process.env.NODE_ENV !== 'production') { - this.addWaypoint('spawn', 0, 0, 0, { }) - } } private updateWaypoints () { From 60fc5ef315ea8b73f58070e271dd8f3dc90fc8e4 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 13 Aug 2025 19:19:46 +0300 Subject: [PATCH 37/86] feat: add skybox renderer: test it by dragging an image window into window, fix waypoint block pos --- renderer/viewer/three/graphicsBackend.ts | 3 + renderer/viewer/three/skyboxRenderer.ts | 77 +++++++++++++++++++++ renderer/viewer/three/waypoints.ts | 2 +- renderer/viewer/three/worldrendererThree.ts | 11 +++ src/dragndrop.ts | 32 +++++++++ 5 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 renderer/viewer/three/skyboxRenderer.ts diff --git a/renderer/viewer/three/graphicsBackend.ts b/renderer/viewer/three/graphicsBackend.ts index 92b6ec66..04cb00ca 100644 --- a/renderer/viewer/three/graphicsBackend.ts +++ b/renderer/viewer/three/graphicsBackend.ts @@ -47,6 +47,9 @@ const getBackendMethods = (worldRenderer: WorldRendererThree) => { addWaypoint: worldRenderer.waypoints.addWaypoint.bind(worldRenderer.waypoints), removeWaypoint: worldRenderer.waypoints.removeWaypoint.bind(worldRenderer.waypoints), + + // New method for updating skybox + setSkyboxImage: worldRenderer.skyboxRenderer.setSkyboxImage.bind(worldRenderer.skyboxRenderer) } } diff --git a/renderer/viewer/three/skyboxRenderer.ts b/renderer/viewer/three/skyboxRenderer.ts new file mode 100644 index 00000000..294c72aa --- /dev/null +++ b/renderer/viewer/three/skyboxRenderer.ts @@ -0,0 +1,77 @@ +import * as THREE from 'three' + +export class SkyboxRenderer { + private texture: THREE.Texture | null = null + private mesh: THREE.Mesh | null = null + + constructor (private readonly scene: THREE.Scene, public initialImage: string | null) {} + + async init () { + if (this.initialImage) { + await this.setSkyboxImage(this.initialImage) + } + } + + async setSkyboxImage (imageUrl: string) { + // Dispose old textures if they exist + if (this.texture) { + this.texture.dispose() + } + + // Load the equirectangular texture + const textureLoader = new THREE.TextureLoader() + this.texture = await new Promise((resolve) => { + textureLoader.load( + imageUrl, + (texture) => { + texture.mapping = THREE.EquirectangularReflectionMapping + texture.encoding = THREE.sRGBEncoding + // Keep pixelated look + texture.minFilter = THREE.NearestFilter + texture.magFilter = THREE.NearestFilter + texture.needsUpdate = true + resolve(texture) + } + ) + }) + + // Create or update the skybox + if (this.mesh) { + // Just update the texture on the existing material + this.mesh.material.map = this.texture + this.mesh.material.needsUpdate = true + } else { + // Create a large sphere geometry for the skybox + const geometry = new THREE.SphereGeometry(500, 60, 40) + // Flip the geometry inside out + geometry.scale(-1, 1, 1) + + // Create material using the loaded texture + const material = new THREE.MeshBasicMaterial({ + map: this.texture, + side: THREE.FrontSide // Changed to FrontSide since we're flipping the geometry + }) + + // Create and add the skybox mesh + this.mesh = new THREE.Mesh(geometry, material) + this.scene.add(this.mesh) + } + } + + update (cameraPosition: THREE.Vector3) { + if (this.mesh) { + this.mesh.position.copy(cameraPosition) + } + } + + dispose () { + if (this.texture) { + this.texture.dispose() + } + if (this.mesh) { + this.mesh.geometry.dispose() + ;(this.mesh.material as THREE.Material).dispose() + this.scene.remove(this.mesh) + } + } +} diff --git a/renderer/viewer/three/waypoints.ts b/renderer/viewer/three/waypoints.ts index 45937ee8..cebd779a 100644 --- a/renderer/viewer/three/waypoints.ts +++ b/renderer/viewer/three/waypoints.ts @@ -85,7 +85,7 @@ export class WaypointsRenderer { this.waypointScene.add(sprite.group) this.waypoints.set(id, { - id, x, y, z, minDistance, + id, x: x + 0.5, y: y + 0.5, z: z + 0.5, minDistance, color, label, sprite, }) diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 82856cb9..fb6c8e11 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -24,6 +24,7 @@ import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' import { Fountain } from './threeJsParticles' import { WaypointsRenderer } from './waypoints' +import { SkyboxRenderer } from './skyboxRenderer' type SectionKey = string @@ -71,6 +72,7 @@ export class WorldRendererThree extends WorldRendererCommon { } fountains: Fountain[] = [] DEBUG_RAYCAST = false + skyboxRenderer: SkyboxRenderer private currentPosTween?: tweenJs.Tween private currentRotTween?: tweenJs.Tween<{ pitch: number, yaw: number }> @@ -94,6 +96,10 @@ export class WorldRendererThree extends WorldRendererCommon { this.holdingBlock = new HoldingBlock(this) this.holdingBlockLeft = new HoldingBlock(this, true) + // Initialize skybox renderer + this.skyboxRenderer = new SkyboxRenderer(this.scene, null) + void this.skyboxRenderer.init() + this.addDebugOverlay() this.resetScene() void this.init() @@ -708,6 +714,10 @@ export class WorldRendererThree extends WorldRendererCommon { this.cursorBlock.render() this.updateSectionOffsets() + // Update skybox position to follow camera + const cameraPos = this.getCameraPosition() + this.skyboxRenderer.update(cameraPos) + const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov if (sizeOrFovChanged) { const size = this.renderer.getSize(new THREE.Vector2()) @@ -947,6 +957,7 @@ export class WorldRendererThree extends WorldRendererCommon { destroy (): void { super.destroy() + this.skyboxRenderer.dispose() } shouldObjectVisible (object: THREE.Object3D) { diff --git a/src/dragndrop.ts b/src/dragndrop.ts index 6be90551..5a16bc05 100644 --- a/src/dragndrop.ts +++ b/src/dragndrop.ts @@ -3,6 +3,7 @@ import fs from 'fs' import * as nbt from 'prismarine-nbt' import RegionFile from 'prismarine-provider-anvil/src/region' import { versions } from 'minecraft-data' +import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' import { openWorldDirectory, openWorldZip } from './browserfs' import { isGameActive } from './globalState' import { showNotification } from './react/NotificationProvider' @@ -12,6 +13,9 @@ const parseNbt = promisify(nbt.parse) const simplifyNbt = nbt.simplify window.nbt = nbt +// Supported image types for skybox +const VALID_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'] + // todo display drop zone for (const event of ['drag', 'dragstart', 'dragend', 'dragover', 'dragenter', 'dragleave', 'drop']) { window.addEventListener(event, (e: any) => { @@ -45,6 +49,34 @@ window.addEventListener('drop', async e => { }) async function handleDroppedFile (file: File) { + // Check for image files first when game is active + if (isGameActive(false) && VALID_IMAGE_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext))) { + try { + // Convert image to base64 + const reader = new FileReader() + const base64Promise = new Promise((resolve, reject) => { + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + }) + reader.readAsDataURL(file) + const base64Image = await base64Promise + + // Get ThreeJS backend methods and update skybox + const setSkyboxImage = getThreeJsRendererMethods()?.setSkyboxImage + if (setSkyboxImage) { + await setSkyboxImage(base64Image) + showNotification('Skybox updated successfully') + } else { + showNotification('Cannot update skybox - renderer does not support it') + } + return + } catch (err) { + console.error('Failed to update skybox:', err) + showNotification('Failed to update skybox', 'error') + return + } + } + if (file.name.endsWith('.zip')) { void openWorldZip(file) return From 15e3325971ed85ee54d5a3022b789a8881a7f4e4 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 14 Aug 2025 01:25:24 +0300 Subject: [PATCH 38/86] add param for testing for immediate reconnect after kick or error (warning: will cause infinite reload loop) --- src/appParams.ts | 1 + src/index.ts | 13 +++++++++---- src/react/AppStatusProvider.tsx | 24 ++++++++++++++---------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/appParams.ts b/src/appParams.ts index 59a24788..8d487f8d 100644 --- a/src/appParams.ts +++ b/src/appParams.ts @@ -12,6 +12,7 @@ export type AppQsParams = { username?: string lockConnect?: string autoConnect?: string + alwaysReconnect?: string // googledrive.ts params state?: string // ServersListProvider.tsx params diff --git a/src/index.ts b/src/index.ts index c0213f56..4a118cee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,7 +62,7 @@ import { onAppLoad, resourcepackReload, resourcePackState } from './resourcePack import { ConnectPeerOptions, connectToPeer } from './localServerMultiplayer' import CustomChannelClient from './customClient' import { registerServiceWorker } from './serviceWorker' -import { appStatusState, lastConnectOptions } from './react/AppStatusProvider' +import { appStatusState, lastConnectOptions, quickDevReconnect } from './react/AppStatusProvider' import { fsState } from './loadSave' import { watchFov } from './rendererUtils' @@ -215,8 +215,13 @@ export async function connect (connectOptions: ConnectOptions) { const destroyAll = (wasKicked = false) => { if (ended) return loadingTimerState.loading = false - if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) { - location.reload() + const { alwaysReconnect } = appQueryParams + if ((!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) || (alwaysReconnect)) { + if (alwaysReconnect === 'quick' || alwaysReconnect === 'fast') { + quickDevReconnect() + } else { + location.reload() + } } errorAbortController.abort() ended = true @@ -957,7 +962,7 @@ const maybeEnterGame = () => { } } - if (appQueryParams.reconnect && process.env.NODE_ENV === 'development') { + if (appQueryParams.reconnect && localStorage.lastConnectOptions && process.env.NODE_ENV === 'development') { const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {}) return waitForConfigFsLoad(async () => { void connect({ diff --git a/src/react/AppStatusProvider.tsx b/src/react/AppStatusProvider.tsx index e7e36cb7..9c7b34ac 100644 --- a/src/react/AppStatusProvider.tsx +++ b/src/react/AppStatusProvider.tsx @@ -54,6 +54,17 @@ export const reconnectReload = () => { } } +export const quickDevReconnect = () => { + if (!lastConnectOptions.value) { + return + } + + resetAppStatusState() + window.dispatchEvent(new window.CustomEvent('connect', { + detail: lastConnectOptions.value + })) +} + export default () => { const lastState = useRef(JSON.parse(JSON.stringify(appStatusState))) const currentState = useSnapshot(appStatusState) @@ -105,13 +116,6 @@ export default () => { } }, [isOpen]) - const reconnect = () => { - resetAppStatusState() - window.dispatchEvent(new window.CustomEvent('connect', { - detail: lastConnectOptions.value - })) - } - useEffect(() => { const controller = new AbortController() window.addEventListener('keyup', (e) => { @@ -119,7 +123,7 @@ export default () => { if (activeModalStack.at(-1)?.reactType !== 'app-status') return // todo do only if reconnect is possible if (e.code !== 'KeyR' || !lastConnectOptions.value) return - reconnect() + quickDevReconnect() }, { signal: controller.signal }) @@ -140,7 +144,7 @@ export default () => { const account = await showOptionsModal('Choose account to connect with', [...accounts.map(account => account.username), 'Use other account']) if (!account) return lastConnectOptions.value!.authenticatedAccount = accounts.find(acc => acc.username === account) || true - reconnect() + quickDevReconnect() } const lastAutoCapturedPackets = getLastAutoCapturedPackets() @@ -184,7 +188,7 @@ export default () => { actionsSlot={ <> {displayAuthButton &&
    ) })} diff --git a/src/react/ChunksDebugScreen.tsx b/src/react/ChunksDebugScreen.tsx index de33e454..28b0bbc4 100644 --- a/src/react/ChunksDebugScreen.tsx +++ b/src/react/ChunksDebugScreen.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import { useUtilsEffect } from '@zardoy/react-util' import { WorldRendererCommon } from 'renderer/viewer/lib/worldrendererCommon' import { WorldRendererThree } from 'renderer/viewer/three/worldrendererThree' +import { Vec3 } from 'vec3' +import { generateSpiralMatrix } from 'flying-squid/dist/utils' import Screen from './Screen' import ChunksDebug, { ChunkDebug } from './ChunksDebug' import { useIsModalActive } from './utilsApp' @@ -12,6 +14,10 @@ const Inner = () => { const [update, setUpdate] = useState(0) useUtilsEffect(({ interval }) => { + const up = () => { + // setUpdate(u => u + 1) + } + bot.on('chunkColumnLoad', up) interval( 500, () => { @@ -20,15 +26,46 @@ const Inner = () => { setUpdate(u => u + 1) } ) + return () => { + bot.removeListener('chunkColumnLoad', up) + } }, []) + // Track first load time for all chunks + const allLoadTimes = Object.values(worldView!.debugChunksInfo) + .map(chunk => chunk?.loads[0]?.time ?? Infinity) + .filter(time => time !== Infinity) + .sort((a, b) => a - b) + + const allSpiralChunks = Object.fromEntries(generateSpiralMatrix(worldView!.viewDistance).map(pos => [`${pos[0]},${pos[1]}`, pos])) + const mapChunk = (key: string, state: ChunkDebug['state']): ChunkDebug => { + const x = Number(key.split(',')[0]) + const z = Number(key.split(',')[1]) + const chunkX = Math.floor(x / 16) + const chunkZ = Math.floor(z / 16) + + delete allSpiralChunks[`${chunkX},${chunkZ}`] const chunk = worldView!.debugChunksInfo[key] + const firstLoadTime = chunk?.loads[0]?.time + const loadIndex = firstLoadTime ? allLoadTimes.indexOf(firstLoadTime) + 1 : 0 + // const timeSinceFirstLoad = firstLoadTime ? firstLoadTime - allLoadTimes[0] : 0 + const timeSinceFirstLoad = firstLoadTime ? firstLoadTime - allLoadTimes[0] : 0 + let line = '' + let line2 = '' + if (loadIndex) { + line = `${loadIndex}` + line2 = `${timeSinceFirstLoad}ms` + } + if (chunk?.loads.length > 1) { + line += ` - ${chunk.loads.length}` + } + return { - x: Number(key.split(',')[0]), - z: Number(key.split(',')[1]), + x, + z, state, - lines: [String(chunk?.loads.length ?? 0)], + lines: [line, line2], sidebarLines: [ `loads: ${chunk?.loads?.map(l => `${l.reason} ${l.dataLength} ${l.time}`).join('\n')}`, // `blockUpdates: ${chunk.blockUpdates}`, @@ -55,14 +92,22 @@ const Inner = () => { const chunksDone = Object.keys(world.finishedChunks).map(key => mapChunk(key, 'done')) + + const chunksWaitingOrder = Object.values(allSpiralChunks).map(([x, z]) => { + const pos = new Vec3(x * 16, 0, z * 16) + if (bot.world.getColumnAt(pos) === null) return null + return mapChunk(`${pos.x},${pos.z}`, 'order-queued') + }).filter(a => !!a) + const allChunks = [ ...chunksWaitingServer, ...chunksWaitingClient, ...clientProcessingChunks, ...chunksDone, ...chunksDoneEmpty, + ...chunksWaitingOrder, ] - return + return Date: Sat, 16 Aug 2025 09:15:37 +0300 Subject: [PATCH 44/86] - Introduced a patchAssets script to apply custom textures to the blocks and items atlases. - Enhanced the ThreeJsSound class to support sound playback timeout and volume adjustments. - Added a custom sound system to handle named sound effects with metadata. --- assets/customTextures/readme.md | 2 + renderer/viewer/three/threeJsSound.ts | 40 ++++++-- rsbuild.config.ts | 4 + scripts/patchAssets.ts | 137 ++++++++++++++++++++++++++ src/basicSounds.ts | 30 +++++- src/defaultOptions.ts | 1 + src/react/Slider.tsx | 58 ++++++++++- src/sounds/botSoundSystem.ts | 11 ++- src/sounds/customSoundSystem.ts | 44 +++++++++ src/sounds/soundsMap.ts | 10 +- src/watchOptions.ts | 4 + 11 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 assets/customTextures/readme.md create mode 100644 scripts/patchAssets.ts create mode 100644 src/sounds/customSoundSystem.ts diff --git a/assets/customTextures/readme.md b/assets/customTextures/readme.md new file mode 100644 index 00000000..e2a78c20 --- /dev/null +++ b/assets/customTextures/readme.md @@ -0,0 +1,2 @@ +here you can place custom textures for bundled files (blocks/items) e.g. blocks/stone.png +get file names from here (blocks/items) https://zardoy.github.io/mc-assets/ diff --git a/renderer/viewer/three/threeJsSound.ts b/renderer/viewer/three/threeJsSound.ts index 46aefda9..699bb2cc 100644 --- a/renderer/viewer/three/threeJsSound.ts +++ b/renderer/viewer/three/threeJsSound.ts @@ -2,7 +2,7 @@ import * as THREE from 'three' import { WorldRendererThree } from './worldrendererThree' export interface SoundSystem { - playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number) => void + playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number, timeout?: number) => void destroy: () => void } @@ -10,7 +10,17 @@ export class ThreeJsSound implements SoundSystem { audioListener: THREE.AudioListener | undefined private readonly activeSounds = new Set() private readonly audioContext: AudioContext | undefined + private readonly soundVolumes = new Map() + baseVolume = 1 + constructor (public worldRenderer: WorldRendererThree) { + worldRenderer.onWorldSwitched.push(() => { + this.stopAll() + }) + + worldRenderer.onReactiveConfigUpdated('volume', (volume) => { + this.changeVolume(volume) + }) } initAudioListener () { @@ -19,20 +29,24 @@ export class ThreeJsSound implements SoundSystem { this.worldRenderer.camera.add(this.audioListener) } - playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1) { + playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1, timeout = 500) { this.initAudioListener() const sound = new THREE.PositionalAudio(this.audioListener!) this.activeSounds.add(sound) + this.soundVolumes.set(sound, volume) const audioLoader = new THREE.AudioLoader() const start = Date.now() void audioLoader.loadAsync(path).then((buffer) => { - if (Date.now() - start > 500) return + if (Date.now() - start > timeout) { + console.warn('Ignored playing sound', path, 'due to timeout:', timeout, 'ms <', Date.now() - start, 'ms') + return + } // play sound.setBuffer(buffer) sound.setRefDistance(20) - sound.setVolume(volume) + sound.setVolume(volume * this.baseVolume) sound.setPlaybackRate(pitch) // set the pitch this.worldRenderer.scene.add(sound) // set sound position @@ -43,21 +57,35 @@ export class ThreeJsSound implements SoundSystem { sound.disconnect() } this.activeSounds.delete(sound) + this.soundVolumes.delete(sound) audioLoader.manager.itemEnd(path) } sound.play() }) } - destroy () { - // Stop and clean up all active sounds + stopAll () { for (const sound of this.activeSounds) { + if (!sound) continue sound.stop() if (sound.source) { sound.disconnect() } + this.worldRenderer.scene.remove(sound) } + this.activeSounds.clear() + this.soundVolumes.clear() + } + changeVolume (volume: number) { + this.baseVolume = volume + for (const [sound, individualVolume] of this.soundVolumes) { + sound.setVolume(individualVolume * this.baseVolume) + } + } + + destroy () { + this.stopAll() // Remove and cleanup audio listener if (this.audioListener) { this.audioListener.removeFromParent() diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 42d6867b..6cd6b2ed 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -240,6 +240,10 @@ const appConfig = defineConfig({ prep() }) build.onAfterBuild(async () => { + if (fs.readdirSync('./assets/customTextures').length > 0) { + childProcess.execSync('tsx ./scripts/patchAssets.ts', { stdio: 'inherit' }) + } + if (SINGLE_FILE_BUILD) { // check that only index.html is in the dist/single folder const singleBuildFiles = fs.readdirSync('./dist/single') diff --git a/scripts/patchAssets.ts b/scripts/patchAssets.ts new file mode 100644 index 00000000..99994f5f --- /dev/null +++ b/scripts/patchAssets.ts @@ -0,0 +1,137 @@ +import blocksAtlas from 'mc-assets/dist/blocksAtlases.json' +import itemsAtlas from 'mc-assets/dist/itemsAtlases.json' +import * as fs from 'fs' +import * as path from 'path' +import sharp from 'sharp' + +interface AtlasFile { + latest: { + suSv: number + tileSize: number + width: number + height: number + textures: { + [key: string]: { + u: number + v: number + su: number + sv: number + tileIndex: number + } + } + } +} + +async function patchTextureAtlas( + atlasType: 'blocks' | 'items', + atlasData: AtlasFile, + customTexturesDir: string, + distDir: string +) { + // Check if custom textures directory exists and has files + if (!fs.existsSync(customTexturesDir) || fs.readdirSync(customTexturesDir).length === 0) { + return + } + + // Find the latest atlas file + const atlasFiles = fs.readdirSync(distDir) + .filter(file => file.startsWith(`${atlasType}AtlasLatest`) && file.endsWith('.png')) + .sort() + + if (atlasFiles.length === 0) { + console.log(`No ${atlasType}AtlasLatest.png found in ${distDir}`) + return + } + + const latestAtlasFile = atlasFiles[atlasFiles.length - 1] + const atlasPath = path.join(distDir, latestAtlasFile) + console.log(`Patching ${atlasPath}`) + + // Get atlas dimensions + const atlasMetadata = await sharp(atlasPath).metadata() + if (!atlasMetadata.width || !atlasMetadata.height) { + throw new Error(`Failed to get atlas dimensions for ${atlasPath}`) + } + + // Process each custom texture + const customTextureFiles = fs.readdirSync(customTexturesDir) + .filter(file => file.endsWith('.png')) + + if (customTextureFiles.length === 0) return + + // Prepare composite operations + const composites: sharp.OverlayOptions[] = [] + + for (const textureFile of customTextureFiles) { + const textureName = path.basename(textureFile, '.png') + + if (atlasData.latest.textures[textureName]) { + const textureData = atlasData.latest.textures[textureName] + const customTexturePath = path.join(customTexturesDir, textureFile) + + try { + // Convert UV coordinates to pixel coordinates + const x = Math.round(textureData.u * atlasMetadata.width) + const y = Math.round(textureData.v * atlasMetadata.height) + const width = Math.round((textureData.su ?? atlasData.latest.suSv) * atlasMetadata.width) + const height = Math.round((textureData.sv ?? atlasData.latest.suSv) * atlasMetadata.height) + + // Resize custom texture to match atlas dimensions and add to composite operations + const resizedTextureBuffer = await sharp(customTexturePath) + .resize(width, height, { + fit: 'fill', + kernel: 'nearest' // Preserve pixel art quality + }) + .png() + .toBuffer() + + composites.push({ + input: resizedTextureBuffer, + left: x, + top: y, + blend: 'over' + }) + + console.log(`Prepared ${textureName} at (${x}, ${y}) with size (${width}, ${height})`) + } catch (error) { + console.error(`Failed to prepare ${textureName}:`, error) + } + } else { + console.warn(`Texture ${textureName} not found in ${atlasType} atlas`) + } + } + + if (composites.length > 0) { + // Apply all patches at once using Sharp's composite + await sharp(atlasPath) + .composite(composites) + .png() + .toFile(atlasPath + '.tmp') + + // Replace original with patched version + fs.renameSync(atlasPath + '.tmp', atlasPath) + console.log(`Saved patched ${atlasType} atlas to ${atlasPath}`) + } +} + +async function main() { + const customBlocksDir = './assets/customTextures/blocks' + const customItemsDir = './assets/customTextures/items' + const distDir = './dist/static/image' + + try { + // Patch blocks atlas + await patchTextureAtlas('blocks', blocksAtlas as unknown as AtlasFile, customBlocksDir, distDir) + + // Patch items atlas + await patchTextureAtlas('items', itemsAtlas as unknown as AtlasFile, customItemsDir, distDir) + + console.log('Texture atlas patching completed!') + } catch (error) { + console.error('Failed to patch texture atlases:', error) + process.exit(1) + } +} + +// Run the script +main() diff --git a/src/basicSounds.ts b/src/basicSounds.ts index 40428c6b..37f8dccd 100644 --- a/src/basicSounds.ts +++ b/src/basicSounds.ts @@ -43,7 +43,7 @@ export async function loadSound (path: string, contents = path) { } } -export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) => { +export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = options.remoteSoundsLoadTimeout, loop = false) => { const soundBuffer = sounds[url] if (!soundBuffer) { const start = Date.now() @@ -51,10 +51,10 @@ export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) = if (cancelled || Date.now() - start > loadTimeout) return } - return playSound(url, soundVolume) + return playSound(url, soundVolume, loop) } -export async function playSound (url, soundVolume = 1) { +export async function playSound (url, soundVolume = 1, loop = false) { const volume = soundVolume * (options.volume / 100) if (!volume) return @@ -75,6 +75,7 @@ export async function playSound (url, soundVolume = 1) { const gainNode = audioContext.createGain() const source = audioContext.createBufferSource() source.buffer = soundBuffer + source.loop = loop source.connect(gainNode) gainNode.connect(audioContext.destination) gainNode.gain.value = volume @@ -99,6 +100,16 @@ export async function playSound (url, soundVolume = 1) { onEnded (callback: () => void) { callbacks.push(callback) }, + stop () { + try { + source.stop() + // Remove from active sounds + const index = activeSounds.findIndex(s => s.source === source) + if (index !== -1) activeSounds.splice(index, 1) + } catch (err) { + console.warn('Failed to stop sound:', err) + } + }, } } @@ -113,6 +124,19 @@ export function stopAllSounds () { activeSounds.length = 0 } +export function stopSound (url: string) { + const soundIndex = activeSounds.findIndex(s => s.source.buffer === sounds[url]) + if (soundIndex !== -1) { + const { source } = activeSounds[soundIndex] + try { + source.stop() + } catch (err) { + console.warn('Failed to stop sound:', err) + } + activeSounds.splice(soundIndex, 1) + } +} + export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) { const normalizedVolume = newVolume / 100 for (const { gainNode, volumeMultiplier } of activeSounds) { diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index 95dbfd63..378cd14a 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -85,6 +85,7 @@ export const defaultOptions = { } as any, preferLoadReadonly: false, experimentalClientSelfReload: true, + remoteSoundsLoadTimeout: 500, disableLoadPrompts: false, guestUsername: 'guest', askGuestName: true, diff --git a/src/react/Slider.tsx b/src/react/Slider.tsx index e177578c..2a068264 100644 --- a/src/react/Slider.tsx +++ b/src/react/Slider.tsx @@ -1,5 +1,5 @@ // Slider.tsx -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef, useCallback } from 'react' import styles from './slider.module.css' import SharedHudVars from './SharedHudVars' @@ -12,6 +12,7 @@ interface Props extends React.ComponentProps<'div'> { min?: number; max?: number; disabledReason?: string; + throttle?: number | false; // milliseconds, default 100, false to disable updateValue?: (value: number) => void; updateOnDragEnd?: boolean; @@ -26,15 +27,24 @@ const Slider: React.FC = ({ min = 0, max = 100, disabledReason, + throttle = 0, updateOnDragEnd = false, updateValue, ...divProps }) => { + label = translate(label) + disabledReason = translate(disabledReason) + valueDisplay = typeof valueDisplay === 'string' ? translate(valueDisplay) : valueDisplay + const [value, setValue] = useState(valueProp) const getRatio = (v = value) => Math.max(Math.min((v - min) / (max - min), 1), 0) const [ratio, setRatio] = useState(getRatio()) + // Throttling refs + const timeoutRef = useRef(null) + const lastValueRef = useRef(valueProp) + useEffect(() => { setValue(valueProp) }, [valueProp]) @@ -42,14 +52,52 @@ const Slider: React.FC = ({ setRatio(getRatio()) }, [value, min, max]) - const fireValueUpdate = (dragEnd: boolean, v = value) => { + const throttledUpdateValue = useCallback((newValue: number, dragEnd: boolean) => { if (updateOnDragEnd !== dragEnd) return - updateValue?.(v) + if (!updateValue) return + + lastValueRef.current = newValue + + if (!throttle) { + // No throttling + updateValue(newValue) + return + } + + // Clear existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + + // Set new timeout + timeoutRef.current = setTimeout(() => { + updateValue(lastValueRef.current) + timeoutRef.current = null + }, throttle) + }, [updateValue, updateOnDragEnd, throttle]) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + // Fire the last value immediately on cleanup + if (updateValue && lastValueRef.current !== undefined) { + updateValue(lastValueRef.current) + } + } + } + }, [updateValue]) + + const fireValueUpdate = (dragEnd: boolean, v = value) => { + throttledUpdateValue(v, dragEnd) } + const labelText = `${label}: ${valueDisplay ?? value} ${unit}` + return ( -
    +
    17 ? 'settings-text-container-long' : ''}`} style={{ width }} {...divProps}> = ({
    diff --git a/src/sounds/botSoundSystem.ts b/src/sounds/botSoundSystem.ts index 72aa3da8..cb237072 100644 --- a/src/sounds/botSoundSystem.ts +++ b/src/sounds/botSoundSystem.ts @@ -11,6 +11,7 @@ import { showNotification } from '../react/NotificationProvider' import { pixelartIcons } from '../react/PixelartIcon' import { createSoundMap, SoundMap } from './soundsMap' import { musicSystem } from './musicSystem' +import './customSoundSystem' let soundMap: SoundMap | undefined @@ -50,8 +51,9 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { appViewer.backend?.soundSystem?.playSound( position, soundData.url, - soundData.volume * (options.volume / 100), - Math.max(Math.min(pitch ?? 1, 2), 0.5) + soundData.volume, + Math.max(Math.min(pitch ?? 1, 2), 0.5), + soundData.timeout ?? options.remoteSoundsLoadTimeout ) } if (getDistance(bot.entity.position, position) < 4 * 16) { @@ -81,7 +83,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } const randomMusicKey = musicKeys[Math.floor(Math.random() * musicKeys.length)] const soundData = await soundMap.getSoundUrl(randomMusicKey) - if (!soundData) return + if (!soundData || !soundMap) return await musicSystem.playMusic(soundData.url, soundData.volume) } @@ -109,6 +111,9 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } bot.on('soundEffectHeard', async (soundId, position, volume, pitch) => { + if (/^https?:/.test(soundId.replace('minecraft:', ''))) { + return + } await playHardcodedSound(soundId, position, volume, pitch) }) diff --git a/src/sounds/customSoundSystem.ts b/src/sounds/customSoundSystem.ts new file mode 100644 index 00000000..5dfa89f7 --- /dev/null +++ b/src/sounds/customSoundSystem.ts @@ -0,0 +1,44 @@ +import { loadOrPlaySound, stopAllSounds, stopSound } from '../basicSounds' + +const customSoundSystem = () => { + bot._client.on('named_sound_effect', packet => { + let { soundName } = packet + let metadata = {} as { loadTimeout?: number, loop?: boolean } + + // Extract JSON metadata from parentheses at the end + const jsonMatch = /\(({.*})\)$/.exec(soundName) + if (jsonMatch) { + try { + metadata = JSON.parse(jsonMatch[1]) + soundName = soundName.slice(0, -jsonMatch[0].length) + } catch (e) { + console.warn('Failed to parse sound metadata:', jsonMatch[1]) + } + } + + if (/^https?:/.test(soundName.replace('minecraft:', ''))) { + const { loadTimeout, loop } = metadata + void loadOrPlaySound(soundName, packet.volume, loadTimeout, loop) + } + }) + + bot._client.on('stop_sound', packet => { + const { flags, source, sound } = packet + + if (flags === 0) { + // Stop all sounds + stopAllSounds() + } else if (sound) { + // Stop specific sound by name + stopSound(sound) + } + }) + + bot.on('end', () => { + stopAllSounds() + }) +} + +customEvents.on('mineflayerBotCreated', () => { + customSoundSystem() +}) diff --git a/src/sounds/soundsMap.ts b/src/sounds/soundsMap.ts index 1b0a0178..47028971 100644 --- a/src/sounds/soundsMap.ts +++ b/src/sounds/soundsMap.ts @@ -35,6 +35,7 @@ interface ResourcePackSoundEntry { name: string stream?: boolean volume?: number + timeout?: number } interface ResourcePackSound { @@ -140,7 +141,7 @@ export class SoundMap { await scan(soundsBasePath) } - async getSoundUrl (soundKey: string, volume = 1): Promise<{ url: string; volume: number } | undefined> { + async getSoundUrl (soundKey: string, volume = 1): Promise<{ url: string; volume: number, timeout?: number } | undefined> { // First check resource pack sounds.json if (this.activeResourcePackSoundsJson && soundKey in this.activeResourcePackSoundsJson) { const rpSound = this.activeResourcePackSoundsJson[soundKey] @@ -151,6 +152,13 @@ export class SoundMap { if (this.activeResourcePackBasePath) { const tryFormat = async (format: string) => { try { + if (sound.name.startsWith('http://') || sound.name.startsWith('https://')) { + return { + url: sound.name, + volume: soundVolume * Math.max(Math.min(volume, 1), 0), + timeout: sound.timeout + } + } const resourcePackPath = path.join(this.activeResourcePackBasePath!, `/assets/minecraft/sounds/${sound.name}.${format}`) const fileData = await fs.promises.readFile(resourcePackPath) return { diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 478da4fb..2f5199c0 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -80,6 +80,10 @@ export const watchOptionsAfterViewerInit = () => { updateFpsLimit(o) }) + watchValue(options, o => { + appViewer.inWorldRenderingConfig.volume = Math.max(o.volume / 100, 0) + }) + watchValue(options, o => { appViewer.inWorldRenderingConfig.vrSupport = o.vrSupport appViewer.inWorldRenderingConfig.vrPageGameRendering = o.vrPageGameRendering From d6eb1601e918b179b11ed2898df2befbd7d93d2d Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 16 Aug 2025 09:16:29 +0300 Subject: [PATCH 45/86] disable remote sounds by default --- src/defaultOptions.ts | 1 + src/sounds/customSoundSystem.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index 378cd14a..28b64059 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -85,6 +85,7 @@ export const defaultOptions = { } as any, preferLoadReadonly: false, experimentalClientSelfReload: true, + remoteSoundsSupport: false, remoteSoundsLoadTimeout: 500, disableLoadPrompts: false, guestUsername: 'guest', diff --git a/src/sounds/customSoundSystem.ts b/src/sounds/customSoundSystem.ts index 5dfa89f7..1880aa70 100644 --- a/src/sounds/customSoundSystem.ts +++ b/src/sounds/customSoundSystem.ts @@ -1,7 +1,9 @@ import { loadOrPlaySound, stopAllSounds, stopSound } from '../basicSounds' +import { options } from '../optionsStorage' const customSoundSystem = () => { bot._client.on('named_sound_effect', packet => { + if (!options.remoteSoundsSupport) return let { soundName } = packet let metadata = {} as { loadTimeout?: number, loop?: boolean } From 9a84a7acfba4a9a7c56fdb4612fa2e35818ae085 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 18 Aug 2025 11:37:20 +0300 Subject: [PATCH 46/86] do less annoying logging --- renderer/viewer/lib/worldDataEmitter.ts | 1 - src/appViewer.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index 3bc2acd3..86a85f77 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -379,7 +379,6 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter { if (!options.experimentalClientSelfReload) return - displayClientChat(`[client] client panicked due to too long loading time. Soft reloading chunks...`) + if (process.env.NODE_ENV === 'development') { + displayClientChat(`[client] client panicked due to too long loading time. Soft reloading chunks...`) + } void reloadChunks() } window.worldView = this.worldView From a8fa3d47d1e43d95313cd19ea1838678e423ba21 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 19 Aug 2025 12:49:33 +0300 Subject: [PATCH 47/86] up protocol & mineflayer for 1.21.6 --- pnpm-lock.yaml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f495c831..e7cb1872 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,13 +137,13 @@ importers: version: 4.17.21 mcraft-fun-mineflayer: specifier: ^0.1.23 - version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/8aaec9f31657ebb5195cc13db09e5fc6760d4a09(encoding@0.1.13)) + version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13)) minecraft-data: specifier: 3.92.0 version: 3.92.0 minecraft-protocol: specifier: github:PrismarineJS/node-minecraft-protocol#master - version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) + version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) mineflayer-item-map-downloader: specifier: github:zardoy/mineflayer-item-map-downloader version: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13) @@ -342,7 +342,7 @@ importers: version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41(@types/react@18.3.18)(react@18.3.1) mineflayer: specifier: github:zardoy/mineflayer#gen-the-master - version: https://codeload.github.com/zardoy/mineflayer/tar.gz/8aaec9f31657ebb5195cc13db09e5fc6760d4a09(encoding@0.1.13) + version: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) mineflayer-mouse: specifier: ^0.1.17 version: 0.1.17 @@ -6649,8 +6649,8 @@ packages: resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41} version: 1.0.1 - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb: - resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb} + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074: + resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074} version: 1.60.1 engines: {node: '>=22'} @@ -6669,8 +6669,8 @@ packages: resolution: {integrity: sha512-0eCR8pnGb42Qd9QmAxOjl0PhA5Fa+9+6H1G/YsbsO5rg5mDf94Tusqp/8NAGLPQCPVDzbarLskXdjR3h0E0bEQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/8aaec9f31657ebb5195cc13db09e5fc6760d4a09: - resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/8aaec9f31657ebb5195cc13db09e5fc6760d4a09} + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8: + resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8} version: 8.0.0 engines: {node: '>=22'} @@ -11308,7 +11308,7 @@ snapshots: dependencies: '@nxg-org/mineflayer-util-plugin': 1.8.4 minecraft-data: 3.92.0 - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/8aaec9f31657ebb5195cc13db09e5fc6760d4a09(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-item: 1.17.0 prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b @@ -13069,7 +13069,7 @@ snapshots: flatmap: 0.0.3 long: 5.3.1 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -13105,7 +13105,7 @@ snapshots: flatmap: 0.0.3 long: 5.3.1 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -16948,12 +16948,12 @@ snapshots: maxrects-packer: '@zardoy/maxrects-packer@2.7.4' zod: 3.24.2 - mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/8aaec9f31657ebb5195cc13db09e5fc6760d4a09(encoding@0.1.13)): + mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/8aaec9f31657ebb5195cc13db09e5fc6760d4a09(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) prismarine-item: 1.17.0 ws: 8.18.1 transitivePeerDependencies: @@ -17271,7 +17271,7 @@ snapshots: - '@types/react' - react - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13): + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13): dependencies: '@types/node-rsa': 1.1.4 '@types/readable-stream': 4.0.18 @@ -17323,7 +17323,7 @@ snapshots: mineflayer-item-map-downloader@https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13): dependencies: - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/8aaec9f31657ebb5195cc13db09e5fc6760d4a09(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) sharp: 0.30.7 transitivePeerDependencies: - encoding @@ -17338,11 +17338,11 @@ snapshots: transitivePeerDependencies: - supports-color - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/8aaec9f31657ebb5195cc13db09e5fc6760d4a09(encoding@0.1.13): + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13): dependencies: '@nxg-org/mineflayer-physics-util': 1.8.10 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/b404bcaed4c039c5558e889c8617aa866cd7bddb(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chat: 1.11.0 From 4a5f2e799ca1778cd45dce9fa3132d66881bec37 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 20 Aug 2025 20:02:57 +0300 Subject: [PATCH 48/86] disable experimentalClientSelfReload by default until it's reworked with more fine-tuned checks against server connection --- src/defaultOptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index 28b64059..6045e70b 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -84,7 +84,7 @@ export const defaultOptions = { gameMode: 1 } as any, preferLoadReadonly: false, - experimentalClientSelfReload: true, + experimentalClientSelfReload: false, remoteSoundsSupport: false, remoteSoundsLoadTimeout: 500, disableLoadPrompts: false, From 72e9e656cca70d66372ddfa722abdf01fb4981e3 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 20 Aug 2025 20:42:40 +0300 Subject: [PATCH 49/86] new! helpful errors on custom channels payloads! --- patches/minecraft-protocol.patch | 13 +++++++++++++ src/mineflayer/mc-protocol.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/patches/minecraft-protocol.patch b/patches/minecraft-protocol.patch index 5dec44d7..e29f87d9 100644 --- a/patches/minecraft-protocol.patch +++ b/patches/minecraft-protocol.patch @@ -73,6 +73,19 @@ index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f8 } function onJoinServerResponse (err) { +diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js +index 671eb452f31e6b5fcd57d715f1009d010160c65f..7f69f511c8fb97d431ec5125c851b49be8e2ab76 100644 +--- a/src/client/pluginChannels.js ++++ b/src/client/pluginChannels.js +@@ -57,7 +57,7 @@ module.exports = function (client, options) { + try { + packet.data = proto.parsePacketBuffer(channel, packet.data).data + } catch (error) { +- client.emit('error', error) ++ client.emit('error', error, { customPayload: packet }) + return + } + } diff --git a/src/client.js b/src/client.js index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..54bb9e6644388e9b6bd42b3012951875989cdf0c 100644 --- a/src/client.js diff --git a/src/mineflayer/mc-protocol.ts b/src/mineflayer/mc-protocol.ts index a0348c5d..0171387a 100644 --- a/src/mineflayer/mc-protocol.ts +++ b/src/mineflayer/mc-protocol.ts @@ -11,6 +11,36 @@ import { getWebsocketStream } from './websocket-core' let lastPacketTime = 0 customEvents.on('mineflayerBotCreated', () => { + // const oldParsePacketBuffer = bot._client.deserializer.parsePacketBuffer + // try { + // const parsed = oldParsePacketBuffer(buffer) + // } catch (err) { + // debugger + // reportError(new Error(`Error parsing packet ${buffer.subarray(0, 30).toString('hex')}`, { cause: err })) + // throw err + // } + // } + class MinecraftProtocolError extends Error { + constructor (message: string, cause?: Error, public data?: any) { + if (data?.customPayload) { + message += ` (Custom payload: ${data.customPayload.channel})` + } + super(message, { cause }) + this.name = 'MinecraftProtocolError' + } + } + + const onClientError = (err, data) => { + const error = new MinecraftProtocolError(`Minecraft protocol client error: ${err.message}`, err, data) + reportError(error) + } + if (typeof bot._client['_events'].error === 'function') { + // dont report to bot for more explicit error + bot._client['_events'].error = onClientError + } else { + bot._client.on('error' as any, onClientError) + } + // todo move more code here if (!appQueryParams.noPacketsValidation) { (bot._client as unknown as Client).on('packet', (data, packetMeta, buffer, fullBuffer) => { From 6e0d54ea17178a091d8c103a48f82a4723f5b23d Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 20 Aug 2025 20:45:02 +0300 Subject: [PATCH 50/86] up mc-protocol patch --- pnpm-lock.yaml | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7cb1872..c7e1d1b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ overrides: patchedDependencies: minecraft-protocol: - hash: 2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea + hash: 4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b path: patches/minecraft-protocol.patch mineflayer-item-map-downloader@1.2.0: hash: a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad @@ -143,7 +143,7 @@ importers: version: 3.92.0 minecraft-protocol: specifier: github:PrismarineJS/node-minecraft-protocol#master - version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) + version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mineflayer-item-map-downloader: specifier: github:zardoy/mineflayer-item-map-downloader version: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13) @@ -430,13 +430,13 @@ importers: version: 1.3.9 prismarine-block: specifier: github:zardoy/prismarine-block#next-era - version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chunk: specifier: github:zardoy/prismarine-chunk#master version: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) prismarine-schematic: specifier: ^1.2.0 - version: 1.2.3 + version: 1.2.3(prismarine-registry@1.11.0) process: specifier: ^0.11.10 version: 0.11.10 @@ -6651,7 +6651,7 @@ packages: minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074: resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074} - version: 1.60.1 + version: 1.61.0 engines: {node: '>=22'} minecraft-wrap@1.6.0: @@ -11309,7 +11309,7 @@ snapshots: '@nxg-org/mineflayer-util-plugin': 1.8.4 minecraft-data: 3.92.0 mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-item: 1.17.0 prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b vec3: 0.1.10 @@ -13069,7 +13069,7 @@ snapshots: flatmap: 0.0.3 long: 5.3.1 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -13105,7 +13105,7 @@ snapshots: flatmap: 0.0.3 long: 5.3.1 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -16952,7 +16952,7 @@ snapshots: dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) prismarine-item: 1.17.0 ws: 8.18.1 @@ -17271,7 +17271,7 @@ snapshots: - '@types/react' - react - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13): + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13): dependencies: '@types/node-rsa': 1.1.4 '@types/readable-stream': 4.0.18 @@ -17342,9 +17342,9 @@ snapshots: dependencies: '@nxg-org/mineflayer-physics-util': 1.8.10 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=2a88e61fea1825d9fa5f1584fde810421d553d23e45e6dc829c3697ee3358bea)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chat: 1.11.0 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) prismarine-entity: 2.5.0 @@ -18135,7 +18135,7 @@ snapshots: minecraft-data: 3.92.0 prismarine-registry: 1.11.0 - prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9: + prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0): dependencies: minecraft-data: 3.92.0 prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) @@ -18143,6 +18143,8 @@ snapshots: prismarine-item: 1.17.0 prismarine-nbt: 2.7.0 prismarine-registry: 1.11.0 + transitivePeerDependencies: + - prismarine-registry prismarine-chat@1.11.0: dependencies: @@ -18153,7 +18155,7 @@ snapshots: prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0): dependencies: prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-nbt: 2.7.0 prismarine-registry: 1.11.0 smart-buffer: 4.2.0 @@ -18187,7 +18189,7 @@ snapshots: prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.92.0): dependencies: - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) prismarine-nbt: 2.7.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c @@ -18211,16 +18213,18 @@ snapshots: prismarine-registry@1.11.0: dependencies: minecraft-data: 3.92.0 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-nbt: 2.7.0 - prismarine-schematic@1.2.3: + prismarine-schematic@1.2.3(prismarine-registry@1.11.0): dependencies: minecraft-data: 3.92.0 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-nbt: 2.7.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c vec3: 0.1.10 + transitivePeerDependencies: + - prismarine-registry prismarine-windows@2.9.0: dependencies: From a12c61bc6c8f3ede1910dba4567d71d7f907feeb Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 21 Aug 2025 13:21:02 +0300 Subject: [PATCH 51/86] add simple monaco (#418) --- package.json | 1 + pnpm-lock.yaml | 58 +++++++++++++++----- src/core/ideChannels.ts | 106 +++++++++++++++++++++++++++++++++++++ src/customChannels.ts | 2 + src/react/MonacoEditor.css | 58 ++++++++++++++++++++ src/react/MonacoEditor.tsx | 73 +++++++++++++++++++++++++ src/reactUi.tsx | 3 +- 7 files changed, 286 insertions(+), 15 deletions(-) create mode 100644 src/core/ideChannels.ts create mode 100644 src/react/MonacoEditor.css create mode 100644 src/react/MonacoEditor.tsx diff --git a/package.json b/package.json index 1d9dfc0c..a8c2c4e7 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "dependencies": { "@dimaka/interface": "0.0.3-alpha.0", "@floating-ui/react": "^0.26.1", + "@monaco-editor/react": "^4.7.0", "@nxg-org/mineflayer-auto-jump": "^0.7.18", "@nxg-org/mineflayer-tracker": "1.3.0", "@react-oauth/google": "^0.12.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7e1d1b9..8acb9681 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@floating-ui/react': specifier: ^0.26.1 version: 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@nxg-org/mineflayer-auto-jump': specifier: ^0.7.18 version: 0.7.18 @@ -430,13 +433,13 @@ importers: version: 1.3.9 prismarine-block: specifier: github:zardoy/prismarine-block#next-era - version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chunk: specifier: github:zardoy/prismarine-chunk#master version: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) prismarine-schematic: specifier: ^1.2.0 - version: 1.2.3(prismarine-registry@1.11.0) + version: 1.2.3 process: specifier: ^0.11.10 version: 0.11.10 @@ -1989,6 +1992,16 @@ packages: '@module-federation/webpack-bundler-runtime@0.11.2': resolution: {integrity: sha512-WdwIE6QF+MKs/PdVu0cKPETF743JB9PZ62/qf7Uo3gU4fjsUMc37RnbJZ/qB60EaHHfjwp1v6NnhZw1r4eVsnw==} + '@monaco-editor/loader@1.5.0': + resolution: {integrity: sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^18.2.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@msgpack/msgpack@2.8.0': resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} engines: {node: '>= 10'} @@ -6769,6 +6782,9 @@ packages: mojangson@2.0.4: resolution: {integrity: sha512-HYmhgDjr1gzF7trGgvcC/huIg2L8FsVbi/KacRe6r1AswbboGVZDS47SOZlomPuMWvZLas8m9vuHHucdZMwTmQ==} + monaco-editor@0.52.2: + resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + moo@0.5.2: resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} @@ -8373,6 +8389,9 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + static-extend@0.1.2: resolution: {integrity: sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==} engines: {node: '>=0.10.0'} @@ -11254,6 +11273,17 @@ snapshots: '@module-federation/runtime': 0.11.2 '@module-federation/sdk': 0.11.2 + '@monaco-editor/loader@1.5.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@monaco-editor/loader': 1.5.0 + monaco-editor: 0.52.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@msgpack/msgpack@2.8.0': {} '@ndelangen/get-tarball@3.0.9': @@ -11309,7 +11339,7 @@ snapshots: '@nxg-org/mineflayer-util-plugin': 1.8.4 minecraft-data: 3.92.0 mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-item: 1.17.0 prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b vec3: 0.1.10 @@ -17344,7 +17374,7 @@ snapshots: minecraft-data: 3.92.0 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chat: 1.11.0 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) prismarine-entity: 2.5.0 @@ -17461,6 +17491,8 @@ snapshots: dependencies: nearley: 2.20.1 + monaco-editor@0.52.2: {} + moo@0.5.2: {} morgan@1.10.0: @@ -18135,7 +18167,7 @@ snapshots: minecraft-data: 3.92.0 prismarine-registry: 1.11.0 - prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0): + prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9: dependencies: minecraft-data: 3.92.0 prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) @@ -18143,8 +18175,6 @@ snapshots: prismarine-item: 1.17.0 prismarine-nbt: 2.7.0 prismarine-registry: 1.11.0 - transitivePeerDependencies: - - prismarine-registry prismarine-chat@1.11.0: dependencies: @@ -18155,7 +18185,7 @@ snapshots: prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0): dependencies: prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.7.0 prismarine-registry: 1.11.0 smart-buffer: 4.2.0 @@ -18189,7 +18219,7 @@ snapshots: prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.92.0): dependencies: - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) prismarine-nbt: 2.7.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c @@ -18213,18 +18243,16 @@ snapshots: prismarine-registry@1.11.0: dependencies: minecraft-data: 3.92.0 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.7.0 - prismarine-schematic@1.2.3(prismarine-registry@1.11.0): + prismarine-schematic@1.2.3: dependencies: minecraft-data: 3.92.0 - prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.7.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c vec3: 0.1.10 - transitivePeerDependencies: - - prismarine-registry prismarine-windows@2.9.0: dependencies: @@ -19521,6 +19549,8 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 + state-local@1.0.7: {} + static-extend@0.1.2: dependencies: define-property: 0.2.5 diff --git a/src/core/ideChannels.ts b/src/core/ideChannels.ts new file mode 100644 index 00000000..a9c517f7 --- /dev/null +++ b/src/core/ideChannels.ts @@ -0,0 +1,106 @@ +import { proxy } from 'valtio' + +export const ideState = proxy({ + id: '', + contents: '', + line: 0, + column: 0, + language: 'typescript', + title: '', +}) +globalThis.ideState = ideState + +export const registerIdeChannels = () => { + registerIdeOpenChannel() + registerIdeSaveChannel() +} + +const registerIdeOpenChannel = () => { + const CHANNEL_NAME = 'minecraft-web-client:ide-open' + + const packetStructure = [ + 'container', + [ + { + name: 'id', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'language', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'contents', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'line', + type: 'i32' + }, + { + name: 'column', + type: 'i32' + }, + { + name: 'title', + type: ['pstring', { countType: 'i16' }] + } + ] + ] + + bot._client.registerChannel(CHANNEL_NAME, packetStructure, true) + + bot._client.on(CHANNEL_NAME as any, (data) => { + const { id, language, contents, line, column, title } = data + + ideState.contents = contents + ideState.line = line + ideState.column = column + ideState.id = id + ideState.language = language || 'typescript' + ideState.title = title + }) + + console.debug(`registered custom channel ${CHANNEL_NAME} channel`) +} +const IDE_SAVE_CHANNEL_NAME = 'minecraft-web-client:ide-save' +const registerIdeSaveChannel = () => { + + const packetStructure = [ + 'container', + [ + { + name: 'id', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'contents', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'language', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'line', + type: 'i32' + }, + { + name: 'column', + type: 'i32' + }, + ] + ] + bot._client.registerChannel(IDE_SAVE_CHANNEL_NAME, packetStructure, true) +} + +export const saveIde = () => { + bot._client.writeChannel(IDE_SAVE_CHANNEL_NAME, { + id: ideState.id, + contents: ideState.contents, + language: ideState.language, + // todo: reflect updated + line: ideState.line, + column: ideState.column, + }) +} diff --git a/src/customChannels.ts b/src/customChannels.ts index 6d3aa7e9..717c7c93 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -2,6 +2,7 @@ import PItem from 'prismarine-item' import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods' import { options } from './optionsStorage' import { jeiCustomCategories } from './inventoryWindows' +import { registerIdeChannels } from './core/ideChannels' export default () => { customEvents.on('mineflayerBotCreated', async () => { @@ -17,6 +18,7 @@ export default () => { registeredJeiChannel() registerBlockInteractionsCustomizationChannel() registerWaypointChannels() + registerIdeChannels() }) } diff --git a/src/react/MonacoEditor.css b/src/react/MonacoEditor.css new file mode 100644 index 00000000..86d2ad0a --- /dev/null +++ b/src/react/MonacoEditor.css @@ -0,0 +1,58 @@ +.monaco-editor-container { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 16px; + background-color: rgba(0, 0, 0, 0.5); +} + +.monaco-editor-title { + font-size: 20px; + font-weight: bold; + color: #fff; + margin-bottom: 8px; +} + +.monaco-editor-wrapper { + position: relative; + width: 100%; + height: 100%; + max-width: 80vw; + max-height: 80vh; + border: 3px solid #000; + background-color: #000; + padding: 3px; + box-shadow: inset 0 0 0 1px #fff, inset 0 0 0 2px #000; +} + +.monaco-editor-close { + position: fixed; + top: 16px; + left: 16px; + z-index: 1001; + cursor: pointer; + padding: 8px; +} + +@media (max-width: 768px) { + .monaco-editor-container { + padding: 0; + } + .monaco-editor-wrapper { + max-width: 100%; + max-height: 100%; + border-radius: 0; + } + .monaco-editor-close { + top: 8px; + left: 8px; + } + .monaco-editor-title { + /* todo: make it work on mobile */ + display: none; + } +} diff --git a/src/react/MonacoEditor.tsx b/src/react/MonacoEditor.tsx new file mode 100644 index 00000000..32162b21 --- /dev/null +++ b/src/react/MonacoEditor.tsx @@ -0,0 +1,73 @@ +import { proxy, useSnapshot } from 'valtio' +import { useEffect } from 'react' +import { Editor } from '@monaco-editor/react' +import PixelartIcon, { pixelartIcons } from '../react/PixelartIcon' +import { useIsModalActive } from '../react/utilsApp' +import { showNotification } from '../react/NotificationProvider' +import { hideModal, showModal } from '../globalState' +import { ideState, saveIde } from '../core/ideChannels' +import './MonacoEditor.css' + +export default () => { + const { contents, line, column, id, language, title } = useSnapshot(ideState) + const isModalActive = useIsModalActive('monaco-editor') + const bodyFont = getComputedStyle(document.body).fontFamily + + useEffect(() => { + if (id && !isModalActive) { + showModal({ reactType: 'monaco-editor' }) + } + if (!id && isModalActive) { + hideModal() + } + }, [id]) + + useEffect(() => { + if (!isModalActive && id) { + try { + saveIde() + } catch (err) { + reportError(err) + showNotification('Failed to save the editor', 'Please try again', true) + } + ideState.id = '' + ideState.contents = '' + } + }, [isModalActive]) + + if (!isModalActive) return null + + return
    +
    + { + hideModal() + }} + /> +
    +
    + {title} +
    +
    + { + ideState.contents = value ?? '' + }} + value={contents} + options={{ + fontFamily: bodyFont, + minimap: { + enabled: true, + }, + }} + /> +
    +
    +} diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 4f8c4541..1e6f13eb 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -67,6 +67,7 @@ import GlobalOverlayHints from './react/GlobalOverlayHints' import FullscreenTime from './react/FullscreenTime' import StorageConflictModal from './react/StorageConflictModal' import FireRenderer from './react/FireRenderer' +import MonacoEditor from './react/MonacoEditor' const isFirefox = ua.getBrowser().name === 'Firefox' if (isFirefox) { @@ -248,7 +249,6 @@ const App = () => { - @@ -259,6 +259,7 @@ const App = () => {
    + From bc2972fe99692510643cb482128ab978970a9c2f Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 24 Aug 2025 19:53:44 +0300 Subject: [PATCH 52/86] fix registering custom channels too late (a few ms diff) --- src/customChannels.ts | 19 ++++++++----------- src/resourcePack.ts | 11 ----------- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/src/customChannels.ts b/src/customChannels.ts index 717c7c93..8e70078f 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -7,18 +7,15 @@ import { registerIdeChannels } from './core/ideChannels' export default () => { customEvents.on('mineflayerBotCreated', async () => { if (!options.customChannels) return - await new Promise(resolve => { - bot.once('login', () => { - resolve(true) - }) + bot.once('login', () => { + registerBlockModelsChannel() + registerMediaChannels() + registerSectionAnimationChannels() + registeredJeiChannel() + registerBlockInteractionsCustomizationChannel() + registerWaypointChannels() + registerIdeChannels() }) - registerBlockModelsChannel() - registerMediaChannels() - registerSectionAnimationChannels() - registeredJeiChannel() - registerBlockInteractionsCustomizationChannel() - registerWaypointChannels() - registerIdeChannels() }) } diff --git a/src/resourcePack.ts b/src/resourcePack.ts index e0f0fca4..ea6c73fd 100644 --- a/src/resourcePack.ts +++ b/src/resourcePack.ts @@ -486,17 +486,6 @@ const downloadAndUseResourcePack = async (url: string, progressReporter: Progres } } -const waitForGameEvent = async () => { - if (miscUiState.gameLoaded) return - await new Promise(resolve => { - const listener = () => resolve() - customEvents.once('gameLoaded', listener) - watchUnloadForCleanup(() => { - customEvents.removeListener('gameLoaded', listener) - }) - }) -} - export const onAppLoad = () => { customEvents.on('mineflayerBotCreated', () => { // todo also handle resourcePack From 8f62fbd4daddbdccf61191949889925bed2b4632 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 26 Aug 2025 13:50:36 +0300 Subject: [PATCH 53/86] fix: window title sometimes was not showing up on old versions --- src/chatUtils.ts | 8 ++++++++ src/inventoryWindows.ts | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/chatUtils.ts b/src/chatUtils.ts index 88437bc3..849d5847 100644 --- a/src/chatUtils.ts +++ b/src/chatUtils.ts @@ -118,6 +118,14 @@ export const formatMessage = (message: MessageInput, mcData: IndexedData = globa return msglist } +export const messageToString = (message: MessageInput | string) => { + if (typeof message === 'string') { + return message + } + const msglist = formatMessage(message) + return msglist.map(msg => msg.text).join('') +} + const blockToItemRemaps = { water: 'water_bucket', lava: 'lava_bucket', diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index a9f89d1b..166e42a7 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -57,12 +57,23 @@ export const onGameLoad = () => { return type } + const maybeParseNbtJson = (data: any) => { + if (typeof data === 'string') { + try { + data = JSON.parse(data) + } catch (err) { + // ignore + } + } + return nbt.simplify(data) ?? data + } + bot.on('windowOpen', (win) => { const implementedWindow = implementedContainersGuiMap[mapWindowType(win.type as string, win.inventoryStart)] if (implementedWindow) { - openWindow(implementedWindow, nbt.simplify(win.title as any)) + openWindow(implementedWindow, maybeParseNbtJson(win.title)) } else if (options.unimplementedContainers) { - openWindow('ChestWin', nbt.simplify(win.title as any)) + openWindow('ChestWin', maybeParseNbtJson(win.title)) } else { // todo format displayClientChat(`[client error] cannot open unimplemented window ${win.id} (${win.type}). Slots: ${win.slots.map(item => getItemName(item)).filter(Boolean).join(', ')}`) From 9718610131a44b1d0aa46fdf97d696d0af4dc663 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 27 Aug 2025 12:08:20 +0300 Subject: [PATCH 54/86] ci: add deployment step for mcw-mcraft-page repository in GitHub Actions --- .github/workflows/release.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cbf52251..68ead92f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,6 +49,18 @@ jobs: publish_dir: .vercel/output/static force_orphan: true + # Deploy to mcw-mcraft-page repository (github.mcraft.fun) + - name: Deploy to mcw-mcraft-page repository + uses: peaceiris/actions-gh-pages@v3 + with: + personal_token: ${{ secrets.MCW_MCRAFT_PAGE_DEPLOY_TOKEN }} + external_repository: ${{ github.repository_owner }}/mcw-mcraft-pages + publish_dir: .vercel/output/static + publish_branch: main + destination_dir: docs + cname: github.mcraft.fun + force_orphan: true + - name: Change index.html title run: | # change Minecraft Web Client to Minecraft Web Client — Free Online Browser Version From 2a1746eb7a6bd96f40de9cffbb13d78e422388f2 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 27 Aug 2025 13:33:39 +0300 Subject: [PATCH 55/86] [skip ci] fix repository name --- .github/workflows/release.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68ead92f..3710eebd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,12 +49,11 @@ jobs: publish_dir: .vercel/output/static force_orphan: true - # Deploy to mcw-mcraft-page repository (github.mcraft.fun) - - name: Deploy to mcw-mcraft-page repository + - name: Deploy to mwc-mcraft-pages repository uses: peaceiris/actions-gh-pages@v3 with: personal_token: ${{ secrets.MCW_MCRAFT_PAGE_DEPLOY_TOKEN }} - external_repository: ${{ github.repository_owner }}/mcw-mcraft-pages + external_repository: ${{ github.repository_owner }}/mwc-mcraft-pages publish_dir: .vercel/output/static publish_branch: main destination_dir: docs From 1f240d8c2047de469965076dc23ae72fe534a83a Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 27 Aug 2025 19:50:53 +0300 Subject: [PATCH 56/86] up mouse allowing to disable positive break block --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/customChannels.ts | 14 +------------- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index a8c2c4e7..5dcd9547 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,7 @@ "mc-assets": "^0.2.62", "minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next", "mineflayer": "github:zardoy/mineflayer#gen-the-master", - "mineflayer-mouse": "^0.1.17", + "mineflayer-mouse": "^0.1.21", "npm-run-all": "^4.1.5", "os-browserify": "^0.3.0", "path-browserify": "^1.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8acb9681..6c393ac7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -347,8 +347,8 @@ importers: specifier: github:zardoy/mineflayer#gen-the-master version: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) mineflayer-mouse: - specifier: ^0.1.17 - version: 0.1.17 + specifier: ^0.1.21 + version: 0.1.21 npm-run-all: specifier: ^4.1.5 version: 4.1.5 @@ -6678,8 +6678,8 @@ packages: resolution: {tarball: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824} version: 1.2.0 - mineflayer-mouse@0.1.17: - resolution: {integrity: sha512-0eCR8pnGb42Qd9QmAxOjl0PhA5Fa+9+6H1G/YsbsO5rg5mDf94Tusqp/8NAGLPQCPVDzbarLskXdjR3h0E0bEQ==} + mineflayer-mouse@0.1.21: + resolution: {integrity: sha512-1XTVuw3twIrEcqQ1QRSB8NcStIUEZ+tbxiAG6rOrN/9M4thhtlS5PTJzFdmdrcYgWEBLvuOdJszaKE5zFfiXhg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8: @@ -17359,7 +17359,7 @@ snapshots: - encoding - supports-color - mineflayer-mouse@0.1.17: + mineflayer-mouse@0.1.21: dependencies: change-case: 5.4.4 debug: 4.4.1 diff --git a/src/customChannels.ts b/src/customChannels.ts index 8e70078f..b566f9dd 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -47,19 +47,7 @@ const registerBlockInteractionsCustomizationChannel = () => { registerChannel(CHANNEL_NAME, packetStructure, (data) => { const config = JSON.parse(data.newConfiguration) - if (config.customBreakTime !== undefined && Object.values(config.customBreakTime).every(x => typeof x === 'number')) { - bot.mouse.customBreakTime = config.customBreakTime - } - if (config.customBreakTimeToolAllowance !== undefined) { - bot.mouse.customBreakTimeToolAllowance = new Set(config.customBreakTimeToolAllowance) - } - - if (config.blockPlacePrediction !== undefined) { - bot.mouse.settings.blockPlacePrediction = config.blockPlacePrediction - } - if (config.blockPlacePredictionDelay !== undefined) { - bot.mouse.settings.blockPlacePredictionDelay = config.blockPlacePredictionDelay - } + bot.mouse.setConfigFromPacket(config) }, true) } From e81d608554d8d0e98982df7f3570b70793a0927c Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 27 Aug 2025 19:52:09 +0300 Subject: [PATCH 57/86] fix cname --- .github/workflows/release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3710eebd..3e8c4136 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,6 +49,10 @@ jobs: publish_dir: .vercel/output/static force_orphan: true + # Create CNAME file for custom domain + - name: Create CNAME file + run: echo "github.mcraft.fun" > .vercel/output/static/CNAME + - name: Deploy to mwc-mcraft-pages repository uses: peaceiris/actions-gh-pages@v3 with: @@ -57,7 +61,6 @@ jobs: publish_dir: .vercel/output/static publish_branch: main destination_dir: docs - cname: github.mcraft.fun force_orphan: true - name: Change index.html title From d0d5234ba43c33d11325696a763163196c3206ad Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 31 Aug 2025 18:31:49 +0300 Subject: [PATCH 58/86] fix: stop right click emulation once window got opened eg chest --- src/mineflayer/plugins/mouse.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mineflayer/plugins/mouse.ts b/src/mineflayer/plugins/mouse.ts index fc1ce0fd..14e19345 100644 --- a/src/mineflayer/plugins/mouse.ts +++ b/src/mineflayer/plugins/mouse.ts @@ -110,7 +110,7 @@ const domListeners = (bot: Bot) => { }, { signal: abortController.signal }) bot.mouse.beforeUpdateChecks = () => { - if (!document.hasFocus()) { + if (!document.hasFocus() || !isGameActive(true)) { // deactive all buttons bot.mouse.buttons.fill(false) } From cb82188272ef5bec257efdf0150460136cf267bd Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 31 Aug 2025 19:31:26 +0300 Subject: [PATCH 59/86] add addPing query param for testing --- README.MD | 1 + src/appParams.ts | 1 + src/index.ts | 2 +- src/mineflayer/mc-protocol.ts | 3 ++- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.MD b/README.MD index 7978cee5..82aac011 100644 --- a/README.MD +++ b/README.MD @@ -176,6 +176,7 @@ Server specific: - `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes. - `?autoConnect=true` - Only works then `ip` and `version` parameters are set and `allowAutoConnect` is `true` in config.json! Directly connects to the specified server. Useful for integrates iframes. - `?serversList=` - `` can be a list of servers in the format `ip:version,ip` or a url to a json file with the same format (array) or a txt file with line-delimited list of server IPs. +- `?addPing=` - Add a latency to both sides of the connection. Useful for testing ping issues. For example `?addPing=100` will add 200ms to your ping. Single player specific: diff --git a/src/appParams.ts b/src/appParams.ts index 8d487f8d..4c8ca186 100644 --- a/src/appParams.ts +++ b/src/appParams.ts @@ -47,6 +47,7 @@ export type AppQsParams = { connectText?: string freezeSettings?: string testIosCrash?: string + addPing?: string // Replay params replayFilter?: string diff --git a/src/index.ts b/src/index.ts index 4a118cee..54731a16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -305,7 +305,7 @@ export async function connect (connectOptions: ConnectOptions) { if (connectOptions.server && !connectOptions.viewerWsConnect && !parsedServer.isWebSocket) { console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`) - net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` } }) + net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` }, artificialDelay: appQueryParams.addPing ? Number(appQueryParams.addPing) : undefined }) } const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance diff --git a/src/mineflayer/mc-protocol.ts b/src/mineflayer/mc-protocol.ts index 0171387a..cd21d01f 100644 --- a/src/mineflayer/mc-protocol.ts +++ b/src/mineflayer/mc-protocol.ts @@ -130,7 +130,8 @@ export const setProxy = (proxyParams: ProxyParams) => { net['setProxy']({ hostname: proxy.host, port: proxy.port, - headers: proxyParams.headers + headers: proxyParams.headers, + artificialDelay: appQueryParams.addPing ? Number(appQueryParams.addPing) : undefined }) return { proxy From 513201be87401c53231103da9264693a3ccadb92 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Mon, 1 Sep 2025 08:56:08 +0300 Subject: [PATCH 60/86] up browserify --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c393ac7..6a4773d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,7 +155,7 @@ importers: version: 2.0.4 net-browserify: specifier: github:zardoy/prismarinejs-net-browserify - version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/17fb901e8ea480a52c8fd46373695be172be8aa5 + version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618 node-gzip: specifier: ^1.1.2 version: 1.1.2 @@ -6849,8 +6849,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/17fb901e8ea480a52c8fd46373695be172be8aa5: - resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/17fb901e8ea480a52c8fd46373695be172be8aa5} + net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618: + resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618} version: 0.2.4 nice-try@1.0.5: @@ -17574,7 +17574,7 @@ snapshots: neo-async@2.6.2: {} - net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/17fb901e8ea480a52c8fd46373695be172be8aa5: + net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/e754999ffdea67853bc9b10553b5e9908b40f618: dependencies: body-parser: 1.20.3 express: 4.21.2 From 7e3ba8bece2e4c85bcc8ef7f2caa22627f88d50d Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 2 Sep 2025 19:02:30 +0300 Subject: [PATCH 61/86] up integrated server for the latest fixes and better stability --- package.json | 2 +- pnpm-lock.yaml | 28 ++++++++++++++++++++-------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 5dcd9547..b5f66bfb 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "esbuild-plugin-polyfill-node": "^0.3.0", "express": "^4.18.2", "filesize": "^10.0.12", - "flying-squid": "npm:@zardoy/flying-squid@^0.0.62", + "flying-squid": "npm:@zardoy/flying-squid@^0.0.104", "framer-motion": "^12.9.2", "fs-extra": "^11.1.1", "google-drive-browserfs": "github:zardoy/browserfs#google-drive", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a4773d2..f6188b6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,8 +121,8 @@ importers: specifier: ^10.0.12 version: 10.1.6 flying-squid: - specifier: npm:@zardoy/flying-squid@^0.0.62 - version: '@zardoy/flying-squid@0.0.62(encoding@0.1.13)' + specifier: npm:@zardoy/flying-squid@^0.0.104 + version: '@zardoy/flying-squid@0.0.104(encoding@0.1.13)' framer-motion: specifier: ^12.9.2 version: 12.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3387,13 +3387,13 @@ packages: resolution: {integrity: sha512-6xm38yGVIa6mKm/DUCF2zFFJhERh/QWp1ufm4cNUvxsONBmfPg8uZ9pZBdOmF6qFGr/HlT6ABBkCSx/dlEtvWg==} engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'} - '@zardoy/flying-squid@0.0.49': - resolution: {integrity: sha512-Kt4wr5/R+44tcLU9gjuNG2an9weWeKEpIoKXfsgJN2GGQqdnbd5nBpxfGDdgZ9aMdFugsVW8BsyPZNhj9vbMXA==} + '@zardoy/flying-squid@0.0.104': + resolution: {integrity: sha512-jGhQ7fn7o8UN+mUwZbt9674D37YLuBi+Au4TwKcopCA6huIQdHTFNl2e+0ZSTI5mnhN+NpyVoR3vmtH6L58vHQ==} engines: {node: '>=8'} hasBin: true - '@zardoy/flying-squid@0.0.62': - resolution: {integrity: sha512-M6icydO/yrmwevBhmgKcqEPC63AhWfU/Es9N/uadVrmKaxGm2FQMMLcybbutRYm1xZ6qsdxDUOUZnN56PsVwfQ==} + '@zardoy/flying-squid@0.0.49': + resolution: {integrity: sha512-Kt4wr5/R+44tcLU9gjuNG2an9weWeKEpIoKXfsgJN2GGQqdnbd5nBpxfGDdgZ9aMdFugsVW8BsyPZNhj9vbMXA==} engines: {node: '>=8'} hasBin: true @@ -6444,6 +6444,12 @@ packages: resolution: {integrity: sha512-RYZeD1+joNlPuUpi+tIWkbP0ieVJr+R6IFkI6/8juhSxx9zE4osoSmteybrfspGm8A6u+YbbY1epqRKEMwVR6Q==} engines: {node: '>=18.0.0'} + mc-bridge@0.1.3: + resolution: {integrity: sha512-H9jPt2xEU77itC27dSz3qazHYqN9qVsv4HgMPozg7RqQ1uwgXmEa+ojKIlDtXf/TLJsG6Kv4EbzGa8a1Wh72uA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + minecraft-data: 3.92.0 + mcraft-fun-mineflayer@0.1.23: resolution: {integrity: sha512-qmI1rQQ0Ro5zJdi99rClWLF+mS9JZffgNX2vyWWesS3Hsk3Xblp/8swYTJKHSaFpNgzkVfXV92fEIrBqeH6iKA==} version: 0.1.23 @@ -13088,7 +13094,7 @@ snapshots: '@types/emscripten': 1.40.0 tslib: 1.14.1 - '@zardoy/flying-squid@0.0.49(encoding@0.1.13)': + '@zardoy/flying-squid@0.0.104(encoding@0.1.13)': dependencies: '@tootallnate/once': 2.0.0 chalk: 5.4.1 @@ -13098,11 +13104,13 @@ snapshots: exit-hook: 2.2.1 flatmap: 0.0.3 long: 5.3.1 + mc-bridge: 0.1.3(minecraft-data@3.92.0) minecraft-data: 3.92.0 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 + prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) prismarine-entity: 2.5.0 prismarine-item: 1.17.0 @@ -13124,7 +13132,7 @@ snapshots: - encoding - supports-color - '@zardoy/flying-squid@0.0.62(encoding@0.1.13)': + '@zardoy/flying-squid@0.0.49(encoding@0.1.13)': dependencies: '@tootallnate/once': 2.0.0 chalk: 5.4.1 @@ -16978,6 +16986,10 @@ snapshots: maxrects-packer: '@zardoy/maxrects-packer@2.7.4' zod: 3.24.2 + mc-bridge@0.1.3(minecraft-data@3.92.0): + dependencies: + minecraft-data: 3.92.0 + mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) From 9d54c70fb724884cd98782cdca093aef622095ba Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 2 Sep 2025 19:05:18 +0300 Subject: [PATCH 62/86] use node 22 --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index f913b9b6..e80b7100 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -26,7 +26,7 @@ jobs: uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 22 cache: "pnpm" - name: Move Cypress to dependencies run: | From 70534d8b5a64ed9df64229b8933c6adc2ac61ea4 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Thu, 4 Sep 2025 12:51:56 +0200 Subject: [PATCH 63/86] fix: adding support for newer skin profile data structure in player heads --- renderer/viewer/three/worldrendererThree.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index fb6c8e11..39b8e1de 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -767,12 +767,17 @@ export class WorldRendererThree extends WorldRendererCommon { } renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) { - const textures = blockEntity.SkullOwner?.Properties?.textures[0] - if (!textures) return + let textureData: string + if (blockEntity.SkullOwner) { + textureData = blockEntity.SkullOwner.Properties?.textures[0]?.Value + } else { + textureData = blockEntity.profile?.properties?.find(p => p.name === 'textures')?.value + } + if (!textureData) return try { - const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString()) - let skinUrl = textureData.textures?.SKIN?.url + const decodedData = JSON.parse(Buffer.from(textureData, 'base64').toString()) + let skinUrl = decodedData.textures?.SKIN?.url const { skinTexturesProxy } = this.worldRendererConfig if (skinTexturesProxy) { skinUrl = skinUrl?.replace('http://textures.minecraft.net/', skinTexturesProxy) From 528d8f516b1d8f86552fbf64d8130b138e1aba23 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Thu, 4 Sep 2025 21:55:02 +0300 Subject: [PATCH 64/86] Update worldrendererThree.ts --- renderer/viewer/three/worldrendererThree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 39b8e1de..440061ad 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -769,7 +769,7 @@ export class WorldRendererThree extends WorldRendererCommon { renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) { let textureData: string if (blockEntity.SkullOwner) { - textureData = blockEntity.SkullOwner.Properties?.textures[0]?.Value + textureData = blockEntity.SkullOwner.Properties?.textures?.[0]?.Value } else { textureData = blockEntity.profile?.properties?.find(p => p.name === 'textures')?.value } From b2e36840b9d3531ed352e08e09fc54734026cc51 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 5 Sep 2025 05:02:54 +0300 Subject: [PATCH 65/86] feat: brand new default skybox with fog, better daycycle and colors (#425) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- README.MD | 1 + renderer/viewer/lib/worldDataEmitter.ts | 32 +++ renderer/viewer/lib/worldrendererCommon.ts | 66 +++-- renderer/viewer/three/skyboxRenderer.ts | 281 +++++++++++++++++++- renderer/viewer/three/worldrendererThree.ts | 23 +- src/dayCycle.ts | 46 ---- src/index.ts | 2 - src/watchOptions.ts | 1 + 8 files changed, 375 insertions(+), 77 deletions(-) delete mode 100644 src/dayCycle.ts diff --git a/README.MD b/README.MD index 82aac011..74d4eb41 100644 --- a/README.MD +++ b/README.MD @@ -233,3 +233,4 @@ Only during development: - [https://github.com/ClassiCube/ClassiCube](ClassiCube - Better C# Rewrite) [DEMO](https://www.classicube.net/server/play/?warned=true) - [https://m.eaglercraft.com/](EaglerCraft) - Eaglercraft runnable on mobile (real Minecraft in the browser) +- [js-minecraft](https://github.com/LabyStudio/js-minecraft) - An insanely well done clone from the graphical side that inspired many features here diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index 86a85f77..dfbdb35c 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -7,6 +7,7 @@ import { Vec3 } from 'vec3' import { BotEvents } from 'mineflayer' import { proxy } from 'valtio' import TypedEmitter from 'typed-emitter' +import { Biome } from 'minecraft-data' import { delayedIterator } from '../../playground/shared' import { chunkPos } from './simpleUtils' @@ -28,6 +29,8 @@ export type WorldDataEmitterEvents = { updateLight: (data: { pos: Vec3 }) => void onWorldSwitch: () => void end: () => void + biomeUpdate: (data: { biome: Biome }) => void + biomeReset: () => void } export class WorldDataEmitterWorker extends (EventEmitter as new () => TypedEmitter) { @@ -360,8 +363,37 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter { export const worldCleanup = buildCleanupDecorator('resetWorld') export const defaultWorldRendererConfig = { + // Debug settings showChunkBorders: false, + enableDebugOverlay: false, + + // Performance settings mesherWorkers: 4, - isPlayground: false, - renderEars: true, - skinTexturesProxy: undefined as string | undefined, - // game renderer setting actually - showHand: false, - viewBobbing: false, - extraBlockRenderers: true, - clipWorldBelowY: undefined as number | undefined, + addChunksBatchWaitTime: 200, + _experimentalSmoothChunkLoading: true, + _renderByChunks: false, + + // Rendering engine settings + dayCycle: true, smoothLighting: true, enableLighting: true, starfield: true, - addChunksBatchWaitTime: 200, + renderEntities: true, + extraBlockRenderers: true, + foreground: true, + fov: 75, + volume: 1, + + // Camera visual related settings + showHand: false, + viewBobbing: false, + renderEars: true, + highlightBlockColor: 'blue', + + // Player models + fetchPlayerSkins: true, + skinTexturesProxy: undefined as string | undefined, + + // VR settings vrSupport: true, vrPageGameRendering: true, - renderEntities: true, - fov: 75, - fetchPlayerSkins: true, - highlightBlockColor: 'blue', - foreground: true, - enableDebugOverlay: false, - _experimentalSmoothChunkLoading: true, - _renderByChunks: false, - volume: 1 + + // World settings + clipWorldBelowY: undefined as number | undefined, + isPlayground: false } export type WorldRendererConfig = typeof defaultWorldRendererConfig @@ -496,6 +509,10 @@ export abstract class WorldRendererCommon timeUpdated? (newTime: number): void + biomeUpdated? (biome: any): void + + biomeReset? (): void + updateViewerPosition (pos: Vec3) { this.viewerChunkPosition = pos for (const [key, value] of Object.entries(this.loadedChunks)) { @@ -817,12 +834,9 @@ export abstract class WorldRendererCommon }) worldEmitter.on('time', (timeOfDay) => { + if (!this.worldRendererConfig.dayCycle) return this.timeUpdated?.(timeOfDay) - if (timeOfDay < 0 || timeOfDay > 24_000) { - throw new Error('Invalid time of day. It should be between 0 and 24000.') - } - this.timeOfTheDay = timeOfDay // if (this.worldRendererConfig.skyLight === skyLight) return @@ -831,6 +845,14 @@ export abstract class WorldRendererCommon // (this).rerenderAllChunks?.() // } }) + + worldEmitter.on('biomeUpdate', ({ biome }) => { + this.biomeUpdated?.(biome) + }) + + worldEmitter.on('biomeReset', () => { + this.biomeReset?.() + }) } setBlockStateIdInner (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) { diff --git a/renderer/viewer/three/skyboxRenderer.ts b/renderer/viewer/three/skyboxRenderer.ts index 294c72aa..aa8c3bb6 100644 --- a/renderer/viewer/three/skyboxRenderer.ts +++ b/renderer/viewer/three/skyboxRenderer.ts @@ -1,10 +1,28 @@ import * as THREE from 'three' +export const DEFAULT_TEMPERATURE = 0.75 + export class SkyboxRenderer { private texture: THREE.Texture | null = null private mesh: THREE.Mesh | null = null + private skyMesh: THREE.Mesh | null = null + private voidMesh: THREE.Mesh | null = null - constructor (private readonly scene: THREE.Scene, public initialImage: string | null) {} + // World state + private worldTime = 0 + private partialTicks = 0 + private viewDistance = 4 + private temperature = DEFAULT_TEMPERATURE + private inWater = false + private waterBreathing = false + private fogBrightness = 0 + private prevFogBrightness = 0 + + constructor (private readonly scene: THREE.Scene, public initialImage: string | null) { + if (!initialImage) { + this.createGradientSky() + } + } async init () { if (this.initialImage) { @@ -58,10 +76,255 @@ export class SkyboxRenderer { } } - update (cameraPosition: THREE.Vector3) { - if (this.mesh) { - this.mesh.position.copy(cameraPosition) + update (cameraPosition: THREE.Vector3, newViewDistance: number) { + if (newViewDistance !== this.viewDistance) { + this.viewDistance = newViewDistance + this.updateSkyColors() } + + if (this.mesh) { + // Update skybox position + this.mesh.position.copy(cameraPosition) + } else if (this.skyMesh) { + // Update gradient sky position + this.skyMesh.position.copy(cameraPosition) + this.voidMesh?.position.copy(cameraPosition) + this.updateSkyColors() // Update colors based on time of day + } + } + + // Update world time + updateTime (timeOfDay: number, partialTicks = 0) { + this.worldTime = timeOfDay + this.partialTicks = partialTicks + this.updateSkyColors() + } + + // Update view distance + updateViewDistance (viewDistance: number) { + this.viewDistance = viewDistance + this.updateSkyColors() + } + + // Update temperature (for biome support) + updateTemperature (temperature: number) { + this.temperature = temperature + this.updateSkyColors() + } + + // Update water state + updateWaterState (inWater: boolean, waterBreathing: boolean) { + this.inWater = inWater + this.waterBreathing = waterBreathing + this.updateSkyColors() + } + + private createGradientSky () { + const size = 64 + const scale = 256 / size + 2 + + { + const geometry = new THREE.PlaneGeometry(size * scale * 2, size * scale * 2) + geometry.rotateX(-Math.PI / 2) + geometry.translate(0, 16, 0) + + const material = new THREE.MeshBasicMaterial({ + color: 0xff_ff_ff, + side: THREE.DoubleSide, + depthTest: false + }) + + this.skyMesh = new THREE.Mesh(geometry, material) + this.scene.add(this.skyMesh) + } + + { + const geometry = new THREE.PlaneGeometry(size * scale * 2, size * scale * 2) + geometry.rotateX(-Math.PI / 2) + geometry.translate(0, -16, 0) + + const material = new THREE.MeshBasicMaterial({ + color: 0xff_ff_ff, + side: THREE.DoubleSide, + depthTest: false + }) + + this.voidMesh = new THREE.Mesh(geometry, material) + this.scene.add(this.voidMesh) + } + + this.updateSkyColors() + } + + private getFogColor (partialTicks = 0): THREE.Vector3 { + const angle = this.getCelestialAngle(partialTicks) + let rotation = Math.cos(angle * Math.PI * 2) * 2 + 0.5 + rotation = Math.max(0, Math.min(1, rotation)) + + let x = 0.752_941_2 + let y = 0.847_058_83 + let z = 1 + + x *= (rotation * 0.94 + 0.06) + y *= (rotation * 0.94 + 0.06) + z *= (rotation * 0.91 + 0.09) + + return new THREE.Vector3(x, y, z) + } + + private getSkyColor (x = 0, z = 0, partialTicks = 0): THREE.Vector3 { + const angle = this.getCelestialAngle(partialTicks) + let brightness = Math.cos(angle * 3.141_593 * 2) * 2 + 0.5 + + if (brightness < 0) brightness = 0 + if (brightness > 1) brightness = 1 + + const temperature = this.getTemperature(x, z) + const rgb = this.getSkyColorByTemp(temperature) + + const red = ((rgb >> 16) & 0xff) / 255 + const green = ((rgb >> 8) & 0xff) / 255 + const blue = (rgb & 0xff) / 255 + + return new THREE.Vector3( + red * brightness, + green * brightness, + blue * brightness + ) + } + + private calculateCelestialAngle (time: number, partialTicks: number): number { + const modTime = (time % 24_000) + let angle = (modTime + partialTicks) / 24_000 - 0.25 + + if (angle < 0) { + angle++ + } + if (angle > 1) { + angle-- + } + + angle = 1 - ((Math.cos(angle * Math.PI) + 1) / 2) + angle += (angle - angle) / 3 + + return angle + } + + private getCelestialAngle (partialTicks: number): number { + return this.calculateCelestialAngle(this.worldTime, partialTicks) + } + + private getTemperature (x: number, z: number): number { + return this.temperature + } + + private getSkyColorByTemp (temperature: number): number { + temperature /= 3 + if (temperature < -1) temperature = -1 + if (temperature > 1) temperature = 1 + + const hue = 0.622_222_2 - temperature * 0.05 + const saturation = 0.5 + temperature * 0.1 + const brightness = 1 + + return this.hsbToRgb(hue, saturation, brightness) + } + + private hsbToRgb (hue: number, saturation: number, brightness: number): number { + let r = 0; let g = 0; let b = 0 + if (saturation === 0) { + r = g = b = Math.floor(brightness * 255 + 0.5) + } else { + const h = (hue - Math.floor(hue)) * 6 + const f = h - Math.floor(h) + const p = brightness * (1 - saturation) + const q = brightness * (1 - saturation * f) + const t = brightness * (1 - (saturation * (1 - f))) + switch (Math.floor(h)) { + case 0: + r = Math.floor(brightness * 255 + 0.5) + g = Math.floor(t * 255 + 0.5) + b = Math.floor(p * 255 + 0.5) + break + case 1: + r = Math.floor(q * 255 + 0.5) + g = Math.floor(brightness * 255 + 0.5) + b = Math.floor(p * 255 + 0.5) + break + case 2: + r = Math.floor(p * 255 + 0.5) + g = Math.floor(brightness * 255 + 0.5) + b = Math.floor(t * 255 + 0.5) + break + case 3: + r = Math.floor(p * 255 + 0.5) + g = Math.floor(q * 255 + 0.5) + b = Math.floor(brightness * 255 + 0.5) + break + case 4: + r = Math.floor(t * 255 + 0.5) + g = Math.floor(p * 255 + 0.5) + b = Math.floor(brightness * 255 + 0.5) + break + case 5: + r = Math.floor(brightness * 255 + 0.5) + g = Math.floor(p * 255 + 0.5) + b = Math.floor(q * 255 + 0.5) + break + } + } + return 0xff_00_00_00 | (r << 16) | (g << 8) | (Math.trunc(b)) + } + + private updateSkyColors () { + if (!this.skyMesh || !this.voidMesh) return + + // Update fog brightness with smooth transition + this.prevFogBrightness = this.fogBrightness + const renderDistance = this.viewDistance / 32 + const brightnessAtPosition = 1 // Could be affected by light level in future + const targetBrightness = brightnessAtPosition * (1 - renderDistance) + renderDistance + this.fogBrightness += (targetBrightness - this.fogBrightness) * 0.1 + + // Handle water fog + if (this.inWater) { + const waterViewDistance = this.waterBreathing ? 100 : 5 + this.scene.fog = new THREE.Fog(new THREE.Color(0, 0, 1), 0.0025, waterViewDistance) + this.scene.background = new THREE.Color(0, 0, 1) + + // Update sky and void colors for underwater effect + ;(this.skyMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(0, 0, 1)) + ;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(0, 0, 0.6)) + return + } + + // Normal sky colors + const viewDistance = this.viewDistance * 16 + const viewFactor = 1 - (0.25 + 0.75 * this.viewDistance / 32) ** 0.25 + + const angle = this.getCelestialAngle(this.partialTicks) + const skyColor = this.getSkyColor(0, 0, this.partialTicks) + const fogColor = this.getFogColor(this.partialTicks) + + const brightness = Math.cos(angle * Math.PI * 2) * 2 + 0.5 + const clampedBrightness = Math.max(0, Math.min(1, brightness)) + + // Interpolate fog brightness + const interpolatedBrightness = this.prevFogBrightness + (this.fogBrightness - this.prevFogBrightness) * this.partialTicks + + const red = (fogColor.x + (skyColor.x - fogColor.x) * viewFactor) * clampedBrightness * interpolatedBrightness + const green = (fogColor.y + (skyColor.y - fogColor.y) * viewFactor) * clampedBrightness * interpolatedBrightness + const blue = (fogColor.z + (skyColor.z - fogColor.z) * viewFactor) * clampedBrightness * interpolatedBrightness + + this.scene.background = new THREE.Color(red, green, blue) + this.scene.fog = new THREE.Fog(new THREE.Color(red, green, blue), 0.0025, viewDistance * 2) + + ;(this.skyMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(skyColor.x, skyColor.y, skyColor.z)) + ;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color( + skyColor.x * 0.2 + 0.04, + skyColor.y * 0.2 + 0.04, + skyColor.z * 0.6 + 0.1 + )) } dispose () { @@ -73,5 +336,15 @@ export class SkyboxRenderer { ;(this.mesh.material as THREE.Material).dispose() this.scene.remove(this.mesh) } + if (this.skyMesh) { + this.skyMesh.geometry.dispose() + ;(this.skyMesh.material as THREE.Material).dispose() + this.scene.remove(this.skyMesh) + } + if (this.voidMesh) { + this.voidMesh.geometry.dispose() + ;(this.voidMesh.material as THREE.Material).dispose() + this.scene.remove(this.voidMesh) + } } } diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 440061ad..29e9223c 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -3,6 +3,7 @@ import { Vec3 } from 'vec3' import nbt from 'prismarine-nbt' import PrismarineChatLoader from 'prismarine-chat' import * as tweenJs from '@tweenjs/tween.js' +import { Biome } from 'minecraft-data' import { renderSign } from '../sign-renderer' import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer' import { chunkPos, sectionPos } from '../lib/simpleUtils' @@ -24,7 +25,7 @@ import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' import { Fountain } from './threeJsParticles' import { WaypointsRenderer } from './waypoints' -import { SkyboxRenderer } from './skyboxRenderer' +import { DEFAULT_TEMPERATURE, SkyboxRenderer } from './skyboxRenderer' type SectionKey = string @@ -173,7 +174,10 @@ export class WorldRendererThree extends WorldRendererCommon { override watchReactivePlayerState () { super.watchReactivePlayerState() this.onReactivePlayerStateUpdated('inWater', (value) => { - this.scene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, this.playerStateReactive.waterBreathing ? 100 : 20) : null + this.skyboxRenderer.updateWaterState(value, this.playerStateReactive.waterBreathing) + }) + this.onReactivePlayerStateUpdated('waterBreathing', (value) => { + this.skyboxRenderer.updateWaterState(this.playerStateReactive.inWater, value) }) this.onReactivePlayerStateUpdated('ambientLight', (value) => { if (!value) return @@ -264,6 +268,19 @@ export class WorldRendererThree extends WorldRendererCommon { } else { this.starField.remove() } + + this.skyboxRenderer.updateTime(newTime) + } + + biomeUpdated (biome: Biome): void { + if (biome?.temperature !== undefined) { + this.skyboxRenderer.updateTemperature(biome.temperature) + } + } + + biomeReset (): void { + // Reset to default temperature when biome is unknown + this.skyboxRenderer.updateTemperature(DEFAULT_TEMPERATURE) } getItemRenderData (item: Record, specificProps: ItemSpecificContextProperties) { @@ -716,7 +733,7 @@ export class WorldRendererThree extends WorldRendererCommon { // Update skybox position to follow camera const cameraPos = this.getCameraPosition() - this.skyboxRenderer.update(cameraPos) + this.skyboxRenderer.update(cameraPos, this.viewDistance) const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov if (sizeOrFovChanged) { diff --git a/src/dayCycle.ts b/src/dayCycle.ts deleted file mode 100644 index 50e63a21..00000000 --- a/src/dayCycle.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { options } from './optionsStorage' -import { assertDefined } from './utils' -import { updateBackground } from './water' - -export default () => { - const timeUpdated = () => { - // 0 morning - const dayTotal = 24_000 - const evening = 11_500 - const night = 13_500 - const morningStart = 23_000 - const morningEnd = 23_961 - const timeProgress = options.dayCycleAndLighting ? bot.time.timeOfDay : 0 - - // todo check actual colors - const dayColorRainy = { r: 111 / 255, g: 156 / 255, b: 236 / 255 } - // todo yes, we should make animations (and rain) - // eslint-disable-next-line unicorn/numeric-separators-style - const dayColor = bot.isRaining ? dayColorRainy : { r: 0.6784313725490196, g: 0.8470588235294118, b: 0.9019607843137255 } // lightblue - // let newColor = dayColor - let int = 1 - if (timeProgress < evening) { - // stay dayily - } else if (timeProgress < night) { - const progressNorm = timeProgress - evening - const progressMax = night - evening - int = 1 - progressNorm / progressMax - } else if (timeProgress < morningStart) { - int = 0 - } else if (timeProgress < morningEnd) { - const progressNorm = timeProgress - morningStart - const progressMax = night - morningEnd - int = progressNorm / progressMax - } - // todo need to think wisely how to set these values & also move directional light around! - const colorInt = Math.max(int, 0.1) - updateBackground({ r: dayColor.r * colorInt, g: dayColor.g * colorInt, b: dayColor.b * colorInt }) - if (!options.newVersionsLighting && bot.supportFeature('blockStateId')) { - appViewer.playerState.reactive.ambientLight = Math.max(int, 0.25) - appViewer.playerState.reactive.directionalLight = Math.min(int, 0.5) - } - } - - bot.on('time', timeUpdated) - timeUpdated() -} diff --git a/src/index.ts b/src/index.ts index 54731a16..7764188f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,7 +56,6 @@ import { isCypress } from './standaloneUtils' import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer' import defaultServerOptions from './defaultLocalServerOptions' -import dayCycle from './dayCycle' import { onAppLoad, resourcepackReload, resourcePackState } from './resourcePack' import { ConnectPeerOptions, connectToPeer } from './localServerMultiplayer' @@ -794,7 +793,6 @@ export async function connect (connectOptions: ConnectOptions) { } initMotionTracking() - dayCycle() // Bot position callback const botPosition = () => { diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 2f5199c0..da75cc74 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -128,5 +128,6 @@ export const watchOptionsAfterWorldViewInit = (worldView: WorldDataEmitter) => { appViewer.inWorldRenderingConfig.renderEars = o.renderEars appViewer.inWorldRenderingConfig.showHand = o.showHand appViewer.inWorldRenderingConfig.viewBobbing = o.viewBobbing + appViewer.inWorldRenderingConfig.dayCycle = o.dayCycleAndLighting }) } From 265d02d18d46fbde79437857eb58254fb68b1cf0 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sun, 7 Sep 2025 18:23:13 +0000 Subject: [PATCH 66/86] up protocol for 1.21.8 --- pnpm-lock.yaml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6188b6d..fd8be62b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,13 +140,13 @@ importers: version: 4.17.21 mcraft-fun-mineflayer: specifier: ^0.1.23 - version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13)) + version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13)) minecraft-data: specifier: 3.92.0 version: 3.92.0 minecraft-protocol: specifier: github:PrismarineJS/node-minecraft-protocol#master - version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) + version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mineflayer-item-map-downloader: specifier: github:zardoy/mineflayer-item-map-downloader version: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13) @@ -345,7 +345,7 @@ importers: version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41(@types/react@18.3.18)(react@18.3.1) mineflayer: specifier: github:zardoy/mineflayer#gen-the-master - version: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) + version: https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13) mineflayer-mouse: specifier: ^0.1.21 version: 0.1.21 @@ -6668,8 +6668,8 @@ packages: resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41} version: 1.0.1 - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074: - resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074} + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9: + resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9} version: 1.61.0 engines: {node: '>=22'} @@ -6688,8 +6688,8 @@ packages: resolution: {integrity: sha512-1XTVuw3twIrEcqQ1QRSB8NcStIUEZ+tbxiAG6rOrN/9M4thhtlS5PTJzFdmdrcYgWEBLvuOdJszaKE5zFfiXhg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8: - resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8} + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e: + resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e} version: 8.0.0 engines: {node: '>=22'} @@ -11344,7 +11344,7 @@ snapshots: dependencies: '@nxg-org/mineflayer-util-plugin': 1.8.4 minecraft-data: 3.92.0 - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-item: 1.17.0 prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b @@ -13106,7 +13106,7 @@ snapshots: long: 5.3.1 mc-bridge: 0.1.3(minecraft-data@3.92.0) minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -13143,7 +13143,7 @@ snapshots: flatmap: 0.0.3 long: 5.3.1 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 @@ -16990,12 +16990,12 @@ snapshots: dependencies: minecraft-data: 3.92.0 - mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13)): + mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13) prismarine-item: 1.17.0 ws: 8.18.1 transitivePeerDependencies: @@ -17313,7 +17313,7 @@ snapshots: - '@types/react' - react - minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13): + minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13): dependencies: '@types/node-rsa': 1.1.4 '@types/readable-stream': 4.0.18 @@ -17365,7 +17365,7 @@ snapshots: mineflayer-item-map-downloader@https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13): dependencies: - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13) sharp: 0.30.7 transitivePeerDependencies: - encoding @@ -17380,11 +17380,11 @@ snapshots: transitivePeerDependencies: - supports-color - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/86e65631e79c490021afc63c80091a7bb6019fa8(encoding@0.1.13): + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13): dependencies: '@nxg-org/mineflayer-physics-util': 1.8.10 minecraft-data: 3.92.0 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/c561917bf7e7966911321512c2a6895a3f9da074(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chat: 1.11.0 From 7f7a14ac65105649621ed825245ca23c2f2c5f85 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 8 Sep 2025 04:19:38 +0300 Subject: [PATCH 67/86] feat: Add overlay model viewer. Already integrated into inventory to display player! --- renderer/viewer/lib/createPlayerObject.ts | 55 +++ renderer/viewer/three/entities.ts | 7 +- src/inventoryWindows.ts | 35 ++ src/react/OverlayModelViewer.tsx | 505 ++++++++++++++++++++++ src/reactUi.tsx | 2 + 5 files changed, 598 insertions(+), 6 deletions(-) create mode 100644 renderer/viewer/lib/createPlayerObject.ts create mode 100644 src/react/OverlayModelViewer.tsx diff --git a/renderer/viewer/lib/createPlayerObject.ts b/renderer/viewer/lib/createPlayerObject.ts new file mode 100644 index 00000000..836c8062 --- /dev/null +++ b/renderer/viewer/lib/createPlayerObject.ts @@ -0,0 +1,55 @@ +import { PlayerObject, PlayerAnimation } from 'skinview3d' +import * as THREE from 'three' +import { WalkingGeneralSwing } from '../three/entity/animations' +import { loadSkinImage, stevePngUrl } from './utils/skins' + +export type PlayerObjectType = PlayerObject & { + animation?: PlayerAnimation + realPlayerUuid: string + realUsername: string +} + +export function createPlayerObject (options: { + username?: string + uuid?: string + scale?: number +}): { + playerObject: PlayerObjectType + wrapper: THREE.Group + } { + const wrapper = new THREE.Group() + const playerObject = new PlayerObject() as PlayerObjectType + + playerObject.realPlayerUuid = options.uuid ?? '' + playerObject.realUsername = options.username ?? '' + playerObject.position.set(0, 16, 0) + + // fix issues with starfield + playerObject.traverse((obj) => { + if (obj instanceof THREE.Mesh && obj.material instanceof THREE.MeshStandardMaterial) { + obj.material.transparent = true + } + }) + + wrapper.add(playerObject as any) + const scale = options.scale ?? (1 / 16) + wrapper.scale.set(scale, scale, scale) + wrapper.rotation.set(0, Math.PI, 0) + + // Set up animation + playerObject.animation = new WalkingGeneralSwing() + ;(playerObject.animation as WalkingGeneralSwing).isMoving = false + playerObject.animation.update(playerObject, 0) + + return { playerObject, wrapper } +} + +export const applySkinToPlayerObject = async (playerObject: PlayerObjectType, skinUrl: string) => { + return loadSkinImage(skinUrl || stevePngUrl).then(({ canvas }) => { + const skinTexture = new THREE.CanvasTexture(canvas) + skinTexture.magFilter = THREE.NearestFilter + skinTexture.minFilter = THREE.NearestFilter + skinTexture.needsUpdate = true + playerObject.skin.map = skinTexture as any + }).catch(console.error) +} diff --git a/renderer/viewer/three/entities.ts b/renderer/viewer/three/entities.ts index 7849686b..fad30182 100644 --- a/renderer/viewer/three/entities.ts +++ b/renderer/viewer/three/entities.ts @@ -20,6 +20,7 @@ import { ItemSpecificContextProperties } from '../lib/basePlayerState' import { loadSkinFromUsername, loadSkinImage, stevePngUrl } from '../lib/utils/skins' import { renderComponent } from '../sign-renderer' import { createCanvas } from '../lib/utils' +import { PlayerObjectType } from '../lib/createPlayerObject' import { getBlockMeshFromModel } from './holdingBlock' import { createItemMesh } from './itemMesh' import * as Entity from './entity/EntityMesh' @@ -33,12 +34,6 @@ export const steveTexture = loadThreeJsTextureFromUrl(stevePngUrl) export const TWEEN_DURATION = 120 -type PlayerObjectType = PlayerObject & { - animation?: PlayerAnimation - realPlayerUuid: string - realUsername: string -} - function convert2sComplementToHex (complement: number) { if (complement < 0) { complement = (0xFF_FF_FF_FF + complement + 1) >>> 0 diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index 166e42a7..d16fee20 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -12,6 +12,7 @@ import PrismarineChatLoader from 'prismarine-chat' import * as nbt from 'prismarine-nbt' import { BlockModel } from 'mc-assets' import { renderSlot } from 'renderer/viewer/three/renderSlot' +import { loadSkinFromUsername } from 'renderer/viewer/lib/utils/skins' import Generic95 from '../assets/generic_95.png' import { appReplacableResources } from './generated/resources' import { activeModalStack, hideCurrentModal, hideModal, miscUiState, showModal } from './globalState' @@ -23,6 +24,7 @@ import { getItemDescription } from './itemsDescriptions' import { MessageFormatPart } from './chatUtils' import { GeneralInputItem, getItemMetadata, getItemModelName, getItemNameRaw, RenderItem } from './mineflayer/items' import { playerState } from './mineflayer/playerState' +import { modelViewerState } from './react/OverlayModelViewer' const loadedImagesCache = new Map() const cleanLoadedImagesCache = () => { @@ -40,6 +42,34 @@ export const jeiCustomCategories = proxy({ value: [] as Array<{ id: string, categoryTitle: string, items: any[] }> }) +let remotePlayerSkin: string | undefined | Promise + +export const showInventoryPlayer = () => { + modelViewerState.model = { + positioning: { + windowWidth: 176, + windowHeight: 166, + x: 25, + y: 8, + width: 50, + height: 70, + scaled: true, + onlyInitialScale: true, + followCursor: true, + }, + // models: ['https://bucket.mcraft.fun/sitarbuckss.glb'], + // debug: true, + steveModelSkin: appViewer.playerState.reactive.playerSkin ?? (typeof remotePlayerSkin === 'string' ? remotePlayerSkin : ''), + } + if (remotePlayerSkin === undefined && !appViewer.playerState.reactive.playerSkin) { + remotePlayerSkin = loadSkinFromUsername(bot.username, 'skin').then(a => { + setTimeout(() => { showInventoryPlayer() }, 0) // todo patch instead and make reactive + remotePlayerSkin = a ?? '' + return remotePlayerSkin + }) + } +} + export const onGameLoad = () => { version = bot.version @@ -392,7 +422,12 @@ const openWindow = (type: string | undefined, title: string | any = undefined) = miscUiState.displaySearchInput = false destroyFn() skipClosePacketSending = false + + modelViewerState.model = undefined }) + if (type === undefined) { + showInventoryPlayer() + } cleanLoadedImagesCache() const inv = openItemsCanvas(type) inv.canvasManager.children[0].mobileHelpers = miscUiState.currentTouch diff --git a/src/react/OverlayModelViewer.tsx b/src/react/OverlayModelViewer.tsx new file mode 100644 index 00000000..24dc836d --- /dev/null +++ b/src/react/OverlayModelViewer.tsx @@ -0,0 +1,505 @@ +import { proxy, useSnapshot, subscribe } from 'valtio' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' +import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' +import { applySkinToPlayerObject, createPlayerObject, PlayerObjectType } from '../../renderer/viewer/lib/createPlayerObject' +import { currentScaling } from '../scaleInterface' + +THREE.ColorManagement.enabled = false + +export const modelViewerState = proxy({ + model: undefined as undefined | { + models?: string[] // Array of model URLs (URL itself is the cache key) + steveModelSkin?: string + debug?: boolean + // absolute positioning + positioning: { + windowWidth: number + windowHeight: number + x: number + y: number + width: number + height: number + scaled?: boolean + onlyInitialScale?: boolean + followCursor?: boolean + } + modelCustomization?: { [modelUrl: string]: { color?: string, opacity?: number, metalness?: number, roughness?: number } } + resetRotationOnReleae?: boolean + continiousRender?: boolean + } +}) +globalThis.modelViewerState = modelViewerState + +// Global debug function to get camera and model values +globalThis.getModelViewerValues = () => { + const scene = globalThis.sceneRef?.current + if (!scene) return null + + const { camera, playerObject } = scene + if (!playerObject) return null + + const wrapper = playerObject.parent + if (!wrapper) return null + + const box = new THREE.Box3().setFromObject(wrapper) + const size = box.getSize(new THREE.Vector3()) + const center = box.getCenter(new THREE.Vector3()) + + return { + camera: { + position: camera.position.clone(), + fov: camera.fov, + aspect: camera.aspect + }, + model: { + position: wrapper.position.clone(), + rotation: wrapper.rotation.clone(), + scale: wrapper.scale.clone(), + size, + center + }, + cursor: { + position: globalThis.cursorPosition || { x: 0, y: 0 }, + normalized: globalThis.cursorPosition ? { + x: globalThis.cursorPosition.x * 2 - 1, + y: globalThis.cursorPosition.y * 2 - 1 + } : { x: 0, y: 0 } + }, + visibleArea: { + height: 2 * Math.tan(camera.fov * Math.PI / 180 / 2) * camera.position.z, + width: 2 * Math.tan(camera.fov * Math.PI / 180 / 2) * camera.position.z * camera.aspect + } + } +} + +export default () => { + const { model } = useSnapshot(modelViewerState) + const containerRef = useRef(null) + const sceneRef = useRef<{ + scene: THREE.Scene + camera: THREE.PerspectiveCamera + renderer: THREE.WebGLRenderer + controls: OrbitControls + playerObject?: PlayerObjectType + dispose: () => void + }>() + const initialScale = useMemo(() => { + return currentScaling.scale + }, []) + globalThis.sceneRef = sceneRef + + // Cursor following state + const cursorPosition = useRef({ x: 0, y: 0 }) + const isFollowingCursor = useRef(false) + + // Model management state + const loadedModels = useRef>(new Map()) + const modelLoaders = useRef>(new Map()) + + // Model management functions + const loadModel = (modelUrl: string) => { + if (loadedModels.current.has(modelUrl)) return // Already loaded + + const isGLTF = modelUrl.toLowerCase().endsWith('.gltf') || modelUrl.toLowerCase().endsWith('.glb') + const loader = isGLTF ? new GLTFLoader() : new OBJLoader() + modelLoaders.current.set(modelUrl, loader) + + const onLoad = (object: THREE.Object3D) => { + // Apply customization if available + const customization = model?.modelCustomization?.[modelUrl] + if (customization) { + object.traverse((child) => { + if (child instanceof THREE.Mesh && child.material) { + const material = child.material as THREE.MeshStandardMaterial + if (customization.color) { + material.color.setHex(parseInt(customization.color.replace('#', ''), 16)) + } + if (customization.opacity !== undefined) { + material.opacity = customization.opacity + material.transparent = customization.opacity < 1 + } + if (customization.metalness !== undefined) { + material.metalness = customization.metalness + } + if (customization.roughness !== undefined) { + material.roughness = customization.roughness + } + } + }) + } + + // Center and scale model + const box = new THREE.Box3().setFromObject(object) + const center = box.getCenter(new THREE.Vector3()) + const size = box.getSize(new THREE.Vector3()) + const maxDim = Math.max(size.x, size.y, size.z) + const scale = 2 / maxDim + object.scale.setScalar(scale) + object.position.sub(center.multiplyScalar(scale)) + + // Store the model using URL as key + loadedModels.current.set(modelUrl, object) + sceneRef.current?.scene.add(object) + + // Trigger render + if (sceneRef.current) { + setTimeout(() => { + const render = () => sceneRef.current?.renderer.render(sceneRef.current.scene, sceneRef.current.camera) + render() + }, 0) + } + } + + if (isGLTF) { + (loader as GLTFLoader).load(modelUrl, (gltf) => { + onLoad(gltf.scene) + }) + } else { + (loader as OBJLoader).load(modelUrl, onLoad) + } + } + + const removeModel = (modelUrl: string) => { + const model = loadedModels.current.get(modelUrl) + if (model) { + sceneRef.current?.scene.remove(model) + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + if (child.material) { + if (Array.isArray(child.material)) { + for (const mat of child.material) { + mat.dispose() + } + } else { + child.material.dispose() + } + } + if (child.geometry) { + child.geometry.dispose() + } + } + }) + loadedModels.current.delete(modelUrl) + } + modelLoaders.current.delete(modelUrl) + } + + // Subscribe to model changes + useEffect(() => { + if (!modelViewerState.model?.models) return + + const modelsChanged = () => { + const currentModels = modelViewerState.model?.models || [] + const currentModelUrls = new Set(currentModels) + const loadedModelUrls = new Set(loadedModels.current.keys()) + + // Remove models that are no longer in the state + for (const modelUrl of loadedModelUrls) { + if (!currentModelUrls.has(modelUrl)) { + removeModel(modelUrl) + } + } + + // Add new models + for (const modelUrl of currentModels) { + if (!loadedModelUrls.has(modelUrl)) { + loadModel(modelUrl) + } + } + } + const unsubscribe = subscribe(modelViewerState.model.models, modelsChanged) + + let unmounted = false + setTimeout(() => { + if (unmounted) return + modelsChanged() + }) + + return () => { + unmounted = true + unsubscribe?.() + } + }, [model?.models]) + + useEffect(() => { + if (!model || !containerRef.current) return + + // Setup scene + const scene = new THREE.Scene() + scene.background = null // Transparent background + + // Setup camera with optimal settings for player model viewing + const camera = new THREE.PerspectiveCamera( + 50, // Reduced FOV for better model viewing + model.positioning.width / model.positioning.height, + 0.1, + 1000 + ) + camera.position.set(0, 0, 3) // Position camera to view player model optimally + + // Setup renderer with pixel density awareness + const renderer = new THREE.WebGLRenderer({ alpha: true }) + let scale = window.devicePixelRatio || 1 + if (modelViewerState.model?.positioning.scaled) { + scale *= currentScaling.scale + } + renderer.setPixelRatio(scale) + renderer.setSize(model.positioning.width, model.positioning.height) + containerRef.current.appendChild(renderer.domElement) + + // Setup controls + const controls = new OrbitControls(camera, renderer.domElement) + // controls.enableZoom = false + // controls.enablePan = false + controls.minPolarAngle = Math.PI / 2 // Lock vertical rotation + controls.maxPolarAngle = Math.PI / 2 + controls.enableDamping = true + controls.dampingFactor = 0.05 + + // Add ambient light + const ambientLight = new THREE.AmbientLight(0xff_ff_ff, 1) + scene.add(ambientLight) + + // Cursor following function + const updatePlayerLookAt = () => { + if (!isFollowingCursor.current || !sceneRef.current?.playerObject) return + + const { playerObject } = sceneRef.current + const { x, y } = cursorPosition.current + + // Convert 0-1 cursor position to normalized coordinates (-1 to 1) + const normalizedX = x * 2 - 1 + const normalizedY = y * 2 - 1 // Inverted: top of screen = negative pitch, bottom = positive pitch + + // Calculate head rotation based on cursor position + // Limit head movement to realistic angles + const maxHeadYaw = Math.PI / 3 // 60 degrees + const maxHeadPitch = Math.PI / 4 // 45 degrees + + const headYaw = normalizedX * maxHeadYaw + const headPitch = normalizedY * maxHeadPitch + + // Apply head rotation with smooth interpolation + const lerpFactor = 0.1 // Smooth interpolation factor + playerObject.skin.head.rotation.y = THREE.MathUtils.lerp( + playerObject.skin.head.rotation.y, + headYaw, + lerpFactor + ) + playerObject.skin.head.rotation.x = THREE.MathUtils.lerp( + playerObject.skin.head.rotation.x, + headPitch, + lerpFactor + ) + + // Apply slight body rotation for more natural movement + const bodyYaw = headYaw * 0.3 // Body follows head but with less rotation + playerObject.rotation.y = THREE.MathUtils.lerp( + playerObject.rotation.y, + bodyYaw, + lerpFactor * 0.5 // Slower body movement + ) + + render() + } + + // Render function + const render = () => { + renderer.render(scene, camera) + } + + // Setup animation/render strategy + if (model.continiousRender) { + // Continuous animation loop + const animate = () => { + requestAnimationFrame(animate) + render() + } + animate() + } else { + // Render only on camera movement + controls.addEventListener('change', render) + // Initial render + render() + // Render after model loads + if (model.steveModelSkin !== undefined) { + // Create player model + const { playerObject, wrapper } = createPlayerObject({ + scale: 1 // Start with base scale, will adjust below + }) + + // Calculate proper scale and positioning for camera view + const box = new THREE.Box3().setFromObject(wrapper) + const size = box.getSize(new THREE.Vector3()) + const center = box.getCenter(new THREE.Vector3()) + + // Calculate scale to fit within camera view (considering FOV and distance) + const cameraDistance = camera.position.z + const fov = camera.fov * Math.PI / 180 // Convert to radians + const visibleHeight = 2 * Math.tan(fov / 2) * cameraDistance + const visibleWidth = visibleHeight * (model.positioning.width / model.positioning.height) + + const scaleFactor = Math.min( + (visibleHeight) / size.y, + (visibleWidth) / size.x + ) + + wrapper.scale.multiplyScalar(scaleFactor) + + // Center the player object + wrapper.position.sub(center.multiplyScalar(scaleFactor)) + + // Rotate to face camera (remove the default 180° rotation) + wrapper.rotation.set(0, 0, 0) + + scene.add(wrapper) + sceneRef.current = { + ...sceneRef.current!, + playerObject + } + + void applySkinToPlayerObject(playerObject, model.steveModelSkin).then(() => { + setTimeout(render, 0) + }) + + // Set up cursor following if enabled + if (model.positioning.followCursor) { + isFollowingCursor.current = true + } + } + } + + // Window cursor tracking for followCursor + let lastCursorUpdate = 0 + let waitingRender = false + const handleWindowPointerMove = (event: PointerEvent) => { + if (!model.positioning.followCursor) return + + // Track cursor position as 0-1 across the entire window + const newPosition = { + x: event.clientX / window.innerWidth, + y: event.clientY / window.innerHeight + } + cursorPosition.current = newPosition + globalThis.cursorPosition = newPosition // Expose for debug + lastCursorUpdate = Date.now() + updatePlayerLookAt() + if (!waitingRender) { + requestAnimationFrame(() => { + render() + waitingRender = false + }) + waitingRender = true + } + } + + // Add window event listeners + if (model.positioning.followCursor) { + window.addEventListener('pointermove', handleWindowPointerMove) + isFollowingCursor.current = true + } + + // Store refs for cleanup + sceneRef.current = { + ...sceneRef.current!, + scene, + camera, + renderer, + controls, + dispose () { + if (!model.continiousRender) { + controls.removeEventListener('change', render) + } + if (model.positioning.followCursor) { + window.removeEventListener('pointermove', handleWindowPointerMove) + } + + // Clean up loaded models + for (const [modelUrl, model] of loadedModels.current) { + scene.remove(model) + model.traverse((child) => { + if (child instanceof THREE.Mesh) { + if (child.material) { + if (Array.isArray(child.material)) { + for (const mat of child.material) { + mat.dispose() + } + } else { + child.material.dispose() + } + } + if (child.geometry) { + child.geometry.dispose() + } + } + }) + } + loadedModels.current.clear() + modelLoaders.current.clear() + + const playerObject = sceneRef.current?.playerObject + if (playerObject?.skin.map) { + (playerObject.skin.map as unknown as THREE.Texture).dispose() + } + renderer.dispose() + renderer.domElement?.remove() + } + } + + return () => { + sceneRef.current?.dispose() + } + }, [model]) + + if (!model) return null + + const { x, y, width, height, scaled, onlyInitialScale } = model.positioning + const { windowWidth } = model.positioning + const { windowHeight } = model.positioning + const scaleValue = onlyInitialScale ? initialScale : 'var(--guiScale)' + + return ( +
    +
    +
    +
    +
    + ) +} diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 1e6f13eb..6339686e 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -68,6 +68,7 @@ import FullscreenTime from './react/FullscreenTime' import StorageConflictModal from './react/StorageConflictModal' import FireRenderer from './react/FireRenderer' import MonacoEditor from './react/MonacoEditor' +import OverlayModelViewer from './react/OverlayModelViewer' const isFirefox = ua.getBrowser().name === 'Firefox' if (isFirefox) { @@ -259,6 +260,7 @@ const App = () => {
    + From 739a6fad24478c5b13b03f96babd853c569dcbbc Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 8 Sep 2025 04:34:17 +0300 Subject: [PATCH 68/86] fix lockfile --- pnpm-lock.yaml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd8be62b..516370ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,7 +140,7 @@ importers: version: 4.17.21 mcraft-fun-mineflayer: specifier: ^0.1.23 - version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13)) + version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13)) minecraft-data: specifier: 3.92.0 version: 3.92.0 @@ -345,7 +345,7 @@ importers: version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41(@types/react@18.3.18)(react@18.3.1) mineflayer: specifier: github:zardoy/mineflayer#gen-the-master - version: https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13) + version: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13) mineflayer-mouse: specifier: ^0.1.21 version: 0.1.21 @@ -6670,7 +6670,7 @@ packages: minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9: resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9} - version: 1.61.0 + version: 1.62.0 engines: {node: '>=22'} minecraft-wrap@1.6.0: @@ -6688,8 +6688,8 @@ packages: resolution: {integrity: sha512-1XTVuw3twIrEcqQ1QRSB8NcStIUEZ+tbxiAG6rOrN/9M4thhtlS5PTJzFdmdrcYgWEBLvuOdJszaKE5zFfiXhg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e: - resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e} + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659: + resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659} version: 8.0.0 engines: {node: '>=22'} @@ -11344,7 +11344,7 @@ snapshots: dependencies: '@nxg-org/mineflayer-util-plugin': 1.8.4 minecraft-data: 3.92.0 - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-item: 1.17.0 prismarine-physics: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b @@ -16990,12 +16990,12 @@ snapshots: dependencies: minecraft-data: 3.92.0 - mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13)): + mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13) prismarine-item: 1.17.0 ws: 8.18.1 transitivePeerDependencies: @@ -17365,7 +17365,7 @@ snapshots: mineflayer-item-map-downloader@https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824(patch_hash=a731ebbace2d8790c973ab3a5ba33494a6e9658533a9710dd8ba36f86db061ad)(encoding@0.1.13): dependencies: - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13) sharp: 0.30.7 transitivePeerDependencies: - encoding @@ -17380,7 +17380,7 @@ snapshots: transitivePeerDependencies: - supports-color - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4493c8ccbd6f7e775d76149d838f4386ae959f0e(encoding@0.1.13): + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13): dependencies: '@nxg-org/mineflayer-physics-util': 1.8.10 minecraft-data: 3.92.0 From 0b1183f541bb76a47ed15cfd63ef59340f91be9f Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 8 Sep 2025 04:36:09 +0300 Subject: [PATCH 69/86] up minecraft-data --- package.json | 4 ++-- scripts/makeOptimizedMcData.mjs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b5f66bfb..ff673726 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "jszip": "^3.10.1", "lodash-es": "^4.17.21", "mcraft-fun-mineflayer": "^0.1.23", - "minecraft-data": "3.92.0", + "minecraft-data": "3.98.0", "minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master", "mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader", "mojangson": "^2.0.4", @@ -205,7 +205,7 @@ "diamond-square": "github:zardoy/diamond-square", "prismarine-block": "github:zardoy/prismarine-block#next-era", "prismarine-world": "github:zardoy/prismarine-world#next-era", - "minecraft-data": "3.92.0", + "minecraft-data": "3.98.0", "prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything", "prismarine-physics": "github:zardoy/prismarine-physics", "minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master", diff --git a/scripts/makeOptimizedMcData.mjs b/scripts/makeOptimizedMcData.mjs index 76e0f1c2..a572d067 100644 --- a/scripts/makeOptimizedMcData.mjs +++ b/scripts/makeOptimizedMcData.mjs @@ -371,6 +371,7 @@ console.log('size', fs.lstatSync(filePath).size / 1000 / 1000, gzipSizeFromFileS const { defaultVersion } = MCProtocol const data = MinecraftData(defaultVersion) +console.log('defaultVersion', defaultVersion, !!data) const initialMcData = { [defaultVersion]: { version: data.version, From f24cb49a8728a63cab19b8cc76fdbfb1888566eb Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 8 Sep 2025 04:55:43 +0300 Subject: [PATCH 70/86] up lockfile --- pnpm-lock.yaml | 74 +++++++++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 516370ce..5bcd74a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ overrides: diamond-square: github:zardoy/diamond-square prismarine-block: github:zardoy/prismarine-block#next-era prismarine-world: github:zardoy/prismarine-world#next-era - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 prismarine-provider-anvil: github:zardoy/prismarine-provider-anvil#everything prismarine-physics: github:zardoy/prismarine-physics minecraft-protocol: github:PrismarineJS/node-minecraft-protocol#master @@ -142,8 +142,8 @@ importers: specifier: ^0.1.23 version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13)) minecraft-data: - specifier: 3.92.0 - version: 3.92.0 + specifier: 3.98.0 + version: 3.98.0 minecraft-protocol: specifier: github:PrismarineJS/node-minecraft-protocol#master version: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) @@ -170,7 +170,7 @@ importers: version: 6.1.1 prismarine-provider-anvil: specifier: github:zardoy/prismarine-provider-anvil#everything - version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.92.0) + version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0) prosemirror-example-setup: specifier: ^1.2.2 version: 1.2.3 @@ -436,7 +436,7 @@ importers: version: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chunk: specifier: github:zardoy/prismarine-chunk#master - version: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) + version: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-schematic: specifier: ^1.2.0 version: 1.2.3 @@ -6448,7 +6448,7 @@ packages: resolution: {integrity: sha512-H9jPt2xEU77itC27dSz3qazHYqN9qVsv4HgMPozg7RqQ1uwgXmEa+ojKIlDtXf/TLJsG6Kv4EbzGa8a1Wh72uA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 mcraft-fun-mineflayer@0.1.23: resolution: {integrity: sha512-qmI1rQQ0Ro5zJdi99rClWLF+mS9JZffgNX2vyWWesS3Hsk3Xblp/8swYTJKHSaFpNgzkVfXV92fEIrBqeH6iKA==} @@ -6658,8 +6658,8 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minecraft-data@3.92.0: - resolution: {integrity: sha512-CGfO50svzm+pSRa4Mbq4owsmRKbPCNkSZ3MCOyH+epC7yNjh+PUhPQFHWq72O51qsY7pAB5qM/bJn1ncwG1J5g==} + minecraft-data@3.98.0: + resolution: {integrity: sha512-JAPqJ/TZoxMUlAPPdWUh1v5wdqvYGFSZ4rW9bUtmaKBkGpomDSjw4V02ocBqbxKJvcTtmc5nM/LfN9/0DDqHrQ==} minecraft-folder-path@1.2.0: resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==} @@ -7387,7 +7387,7 @@ packages: prismarine-biome@1.3.0: resolution: {integrity: sha512-GY6nZxq93mTErT7jD7jt8YS1aPrOakbJHh39seYsJFXvueIOdHAmW16kYQVrTVMW5MlWLQVxV/EquRwOgr4MnQ==} peerDependencies: - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 prismarine-registry: ^1.1.0 prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9: @@ -11343,7 +11343,7 @@ snapshots: '@nxg-org/mineflayer-trajectories@1.2.0(encoding@0.1.13)': dependencies: '@nxg-org/mineflayer-util-plugin': 1.8.4 - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-item: 1.17.0 @@ -13104,18 +13104,18 @@ snapshots: exit-hook: 2.2.1 flatmap: 0.0.3 long: 5.3.1 - mc-bridge: 0.1.3(minecraft-data@3.92.0) - minecraft-data: 3.92.0 + mc-bridge: 0.1.3(minecraft-data@3.98.0) + minecraft-data: 3.98.0 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) + prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-entity: 2.5.0 prismarine-item: 1.17.0 prismarine-nbt: 2.7.0 - prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.92.0) + prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0) prismarine-windows: 2.9.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c rambda: 9.4.2 @@ -13142,16 +13142,16 @@ snapshots: exit-hook: 2.2.1 flatmap: 0.0.3 long: 5.3.1 - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) + prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-entity: 2.5.0 prismarine-item: 1.17.0 prismarine-nbt: 2.7.0 - prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.92.0) + prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0) prismarine-windows: 2.9.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c rambda: 9.4.2 @@ -14542,8 +14542,8 @@ snapshots: diamond-square@https://codeload.github.com/zardoy/diamond-square/tar.gz/cfaad2d1d5909fdfa63c8cc7bc05fb5e87782d71: dependencies: - minecraft-data: 3.92.0 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) + minecraft-data: 3.98.0 + prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-registry: 1.11.0 random-seed: 0.3.0 vec3: 0.1.10 @@ -16986,9 +16986,9 @@ snapshots: maxrects-packer: '@zardoy/maxrects-packer@2.7.4' zod: 3.24.2 - mc-bridge@0.1.3(minecraft-data@3.92.0): + mc-bridge@0.1.3(minecraft-data@3.98.0): dependencies: - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13)): dependencies: @@ -17302,7 +17302,7 @@ snapshots: min-indent@1.0.1: {} - minecraft-data@3.92.0: {} + minecraft-data@3.98.0: {} minecraft-folder-path@1.2.0: {} @@ -17322,7 +17322,7 @@ snapshots: debug: 4.4.0(supports-color@8.1.1) endian-toggle: 0.0.0 lodash.merge: 4.6.2 - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 minecraft-folder-path: 1.2.0 node-fetch: 2.7.0(encoding@0.1.13) node-rsa: 0.4.2 @@ -17383,12 +17383,12 @@ snapshots: mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/dd3b1ff38506d6f72d90e8444186e4e75fe82659(encoding@0.1.13): dependencies: '@nxg-org/mineflayer-physics-util': 1.8.10 - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/bf89f7e86526c54d8c43f555d8f6dfa4948fd2d9(patch_hash=4ebdae314c68d01ce7879445c0b8bde5f90373abba8b66ed00d42e7a5f542f8b)(encoding@0.1.13) - prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) + prismarine-biome: 1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-chat: 1.11.0 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) + prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-entity: 2.5.0 prismarine-item: 1.17.0 prismarine-nbt: 2.7.0 @@ -18174,15 +18174,15 @@ snapshots: transitivePeerDependencies: - supports-color - prismarine-biome@1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0): + prismarine-biome@1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0): dependencies: - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 prismarine-registry: 1.11.0 prismarine-block@https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9: dependencies: - minecraft-data: 3.92.0 - prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) + minecraft-data: 3.98.0 + prismarine-biome: 1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0) prismarine-chat: 1.11.0 prismarine-item: 1.17.0 prismarine-nbt: 2.7.0 @@ -18194,9 +18194,9 @@ snapshots: prismarine-nbt: 2.7.0 prismarine-registry: 1.11.0 - prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0): + prismarine-chunk@https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0): dependencies: - prismarine-biome: 1.3.0(minecraft-data@3.92.0)(prismarine-registry@1.11.0) + prismarine-biome: 1.3.0(minecraft-data@3.98.0)(prismarine-registry@1.11.0) prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.7.0 prismarine-registry: 1.11.0 @@ -18225,14 +18225,14 @@ snapshots: prismarine-physics@https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b: dependencies: - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 prismarine-nbt: 2.7.0 vec3: 0.1.10 - prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.92.0): + prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.98.0): dependencies: prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 - prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.92.0) + prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/c5feac83b61d95feb4d4f22c063dacfb8c192a9f(minecraft-data@3.98.0) prismarine-nbt: 2.7.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c uint4: 0.1.2 @@ -18254,13 +18254,13 @@ snapshots: prismarine-registry@1.11.0: dependencies: - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.7.0 prismarine-schematic@1.2.3: dependencies: - minecraft-data: 3.92.0 + minecraft-data: 3.98.0 prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9 prismarine-nbt: 2.7.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c From 1525fac2a192f1fe7e2f858480c444948bbe9651 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 8 Sep 2025 05:22:24 +0300 Subject: [PATCH 71/86] fix: some visual camera world view issues (visible lines between blocks) --- renderer/viewer/three/cameraShake.ts | 25 ++++++++++++++++++++-- renderer/viewer/three/world/cursorBlock.ts | 18 ++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/renderer/viewer/three/cameraShake.ts b/renderer/viewer/three/cameraShake.ts index 593b4628..7b159509 100644 --- a/renderer/viewer/three/cameraShake.ts +++ b/renderer/viewer/three/cameraShake.ts @@ -80,8 +80,12 @@ export class CameraShake { camera.setRotationFromQuaternion(yawQuat) } else { // For regular camera, apply all rotations - const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), this.basePitch) - const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), this.baseYaw) + // Add tiny offsets to prevent z-fighting at ideal angles (90, 180, 270 degrees) + const pitchOffset = this.addAntiZfightingOffset(this.basePitch) + const yawOffset = this.addAntiZfightingOffset(this.baseYaw) + + const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitchOffset) + const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawOffset) const rollQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(this.rollAngle)) // Combine rotations in the correct order: pitch -> yaw -> roll const finalQuat = yawQuat.multiply(pitchQuat).multiply(rollQuat) @@ -96,4 +100,21 @@ export class CameraShake { private easeInOut (t: number): number { return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2 } + + private addAntiZfightingOffset (angle: number): number { + const offset = 0.001 // Very small offset in radians (about 0.057 degrees) + + // Check if the angle is close to ideal angles (0, π/2, π, 3π/2) + const normalizedAngle = ((angle % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) + const tolerance = 0.01 // Tolerance for considering an angle "ideal" + + if (Math.abs(normalizedAngle) < tolerance || + Math.abs(normalizedAngle - Math.PI / 2) < tolerance || + Math.abs(normalizedAngle - Math.PI) < tolerance || + Math.abs(normalizedAngle - 3 * Math.PI / 2) < tolerance) { + return angle + offset + } + + return angle + } } diff --git a/renderer/viewer/three/world/cursorBlock.ts b/renderer/viewer/three/world/cursorBlock.ts index b71c1b8d..a03a6999 100644 --- a/renderer/viewer/three/world/cursorBlock.ts +++ b/renderer/viewer/three/world/cursorBlock.ts @@ -28,7 +28,7 @@ export class CursorBlock { } cursorLineMaterial: LineMaterial - interactionLines: null | { blockPos: Vec3, mesh: THREE.Group } = null + interactionLines: null | { blockPos: Vec3, mesh: THREE.Group, shapePositions: BlocksShapes | undefined } = null prevColor: string | undefined blockBreakMesh: THREE.Mesh breakTextures: THREE.Texture[] = [] @@ -62,6 +62,13 @@ export class CursorBlock { this.worldRenderer.onReactivePlayerStateUpdated('gameMode', () => { this.updateLineMaterial() }) + // todo figure out why otherwise fog from skybox breaks it + setTimeout(() => { + this.updateLineMaterial() + if (this.interactionLines) { + this.setHighlightCursorBlock(this.interactionLines.blockPos, this.interactionLines.shapePositions, true) + } + }) } // Update functions @@ -69,6 +76,9 @@ export class CursorBlock { const inCreative = this.worldRenderer.playerStateReactive.gameMode === 'creative' const pixelRatio = this.worldRenderer.renderer.getPixelRatio() + if (this.cursorLineMaterial) { + this.cursorLineMaterial.dispose() + } this.cursorLineMaterial = new LineMaterial({ color: (() => { switch (this.worldRenderer.worldRendererConfig.highlightBlockColor) { @@ -115,8 +125,8 @@ export class CursorBlock { } } - setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes): void { - if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos)) { + setHighlightCursorBlock (blockPos: Vec3 | null, shapePositions?: BlocksShapes, force = false): void { + if (blockPos && this.interactionLines && blockPos.equals(this.interactionLines.blockPos) && !force) { return } if (this.interactionLines !== null) { @@ -140,7 +150,7 @@ export class CursorBlock { } this.worldRenderer.scene.add(group) group.visible = !this.cursorLinesHidden - this.interactionLines = { blockPos, mesh: group } + this.interactionLines = { blockPos, mesh: group, shapePositions } } render () { From c4097975bf8ea14764bf7f2b70aa00e45dc5b399 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 8 Sep 2025 05:29:34 +0300 Subject: [PATCH 72/86] add a way to disable sky box for old behavior (not tested) --- renderer/viewer/lib/worldrendererCommon.ts | 1 + renderer/viewer/three/skyboxRenderer.ts | 25 ++++++++++++++++++++- renderer/viewer/three/worldrendererThree.ts | 5 ++++- src/defaultOptions.ts | 1 + src/watchOptions.ts | 4 ++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index e2455915..4140e3fa 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -47,6 +47,7 @@ export const defaultWorldRendererConfig = { smoothLighting: true, enableLighting: true, starfield: true, + defaultSkybox: true, renderEntities: true, extraBlockRenderers: true, foreground: true, diff --git a/renderer/viewer/three/skyboxRenderer.ts b/renderer/viewer/three/skyboxRenderer.ts index aa8c3bb6..cd7bd879 100644 --- a/renderer/viewer/three/skyboxRenderer.ts +++ b/renderer/viewer/three/skyboxRenderer.ts @@ -18,7 +18,7 @@ export class SkyboxRenderer { private fogBrightness = 0 private prevFogBrightness = 0 - constructor (private readonly scene: THREE.Scene, public initialImage: string | null) { + constructor (private readonly scene: THREE.Scene, public defaultSkybox: boolean, public initialImage: string | null) { if (!initialImage) { this.createGradientSky() } @@ -119,6 +119,12 @@ export class SkyboxRenderer { this.updateSkyColors() } + // Update default skybox setting + updateDefaultSkybox (defaultSkybox: boolean) { + this.defaultSkybox = defaultSkybox + this.updateSkyColors() + } + private createGradientSky () { const size = 64 const scale = 256 / size + 2 @@ -279,6 +285,23 @@ export class SkyboxRenderer { private updateSkyColors () { if (!this.skyMesh || !this.voidMesh) return + // If default skybox is disabled, hide the skybox meshes + if (!this.defaultSkybox) { + this.skyMesh.visible = false + this.voidMesh.visible = false + if (this.mesh) { + this.mesh.visible = false + } + return + } + + // Show skybox meshes when default skybox is enabled + this.skyMesh.visible = true + this.voidMesh.visible = true + if (this.mesh) { + this.mesh.visible = true + } + // Update fog brightness with smooth transition this.prevFogBrightness = this.fogBrightness const renderDistance = this.viewDistance / 32 diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index 29e9223c..1b4e6152 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -98,7 +98,7 @@ export class WorldRendererThree extends WorldRendererCommon { this.holdingBlockLeft = new HoldingBlock(this, true) // Initialize skybox renderer - this.skyboxRenderer = new SkyboxRenderer(this.scene, null) + this.skyboxRenderer = new SkyboxRenderer(this.scene, this.worldRendererConfig.defaultSkybox, null) void this.skyboxRenderer.init() this.addDebugOverlay() @@ -206,6 +206,9 @@ export class WorldRendererThree extends WorldRendererCommon { this.onReactiveConfigUpdated('showChunkBorders', (value) => { this.updateShowChunksBorder(value) }) + this.onReactiveConfigUpdated('defaultSkybox', (value) => { + this.skyboxRenderer.updateDefaultSkybox(value) + }) } changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) { diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index 6045e70b..361879be 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -41,6 +41,7 @@ export const defaultOptions = { renderEars: true, lowMemoryMode: false, starfieldRendering: true, + defaultSkybox: true, enabledResourcepack: null as string | null, useVersionsTextures: 'latest', serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never', diff --git a/src/watchOptions.ts b/src/watchOptions.ts index da75cc74..779aa29f 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -116,6 +116,10 @@ export const watchOptionsAfterViewerInit = () => { appViewer.inWorldRenderingConfig.starfield = o.starfieldRendering }) + watchValue(options, o => { + appViewer.inWorldRenderingConfig.defaultSkybox = o.defaultSkybox + }) + watchValue(options, o => { // appViewer.inWorldRenderingConfig.neighborChunkUpdates = o.neighborChunkUpdates }) From 06dc3cb033129f925dd492b01a809e9e5f3cd19c Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 8 Sep 2025 05:38:16 +0300 Subject: [PATCH 73/86] feat: Add saveLoginPassword option to control password saving behavior in browser for offline auth on servers --- src/defaultOptions.ts | 1 + src/optionsGuiScheme.tsx | 10 ++++++++++ src/react/ChatProvider.tsx | 20 ++++++++++++++++---- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index 361879be..85ebae17 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -84,6 +84,7 @@ export const defaultOptions = { localServerOptions: { gameMode: 1 } as any, + saveLoginPassword: 'prompt' as 'prompt' | 'never' | 'always', preferLoadReadonly: false, experimentalClientSelfReload: false, remoteSoundsSupport: false, diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index b03db37d..a47c06eb 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -550,6 +550,16 @@ export const guiOptionsScheme: { return Server Connection }, }, + { + saveLoginPassword: { + tooltip: 'Controls whether to save login passwords for servers in this browser memory.', + values: [ + 'prompt', + 'always', + 'never' + ] + }, + }, { custom () { const { serversAutoVersionSelect } = useSnapshot(options) diff --git a/src/react/ChatProvider.tsx b/src/react/ChatProvider.tsx index 0bb13285..066bc48a 100644 --- a/src/react/ChatProvider.tsx +++ b/src/react/ChatProvider.tsx @@ -73,16 +73,28 @@ export default () => { } const builtinHandled = tryHandleBuiltinCommand(message) - if (getServerIndex() !== undefined && (message.startsWith('/login') || message.startsWith('/register'))) { - showNotification('Click here to save your password in browser for auto-login', undefined, false, undefined, () => { + if (getServerIndex() !== undefined && (message.startsWith('/login') || message.startsWith('/register')) && options.saveLoginPassword !== 'never') { + const savePassword = () => { + let hadPassword = false updateLoadedServerData((server) => { server.autoLogin ??= {} const password = message.split(' ')[1] + hadPassword = !!server.autoLogin[bot.username] server.autoLogin[bot.username] = password return { ...server } }) - hideNotification() - }) + if (options.saveLoginPassword === 'always') { + const message = hadPassword ? 'Password updated in browser for auto-login' : 'Password saved in browser for auto-login' + showNotification(message, undefined, false, undefined) + } else { + hideNotification() + } + } + if (options.saveLoginPassword === 'prompt') { + showNotification('Click here to save your password in browser for auto-login', undefined, false, undefined, savePassword) + } else { + savePassword() + } notificationProxy.id = 'auto-login' const listener = () => { hideNotification() From 852dd737aefc045a1d4193d17803d10a0cce1b48 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Thu, 11 Sep 2025 22:24:04 +0300 Subject: [PATCH 74/86] fix: fix some UI like error screen was not visible fully (buttons were clipped behind the screen) on larger scale on large screens --- src/screens.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/screens.css b/src/screens.css index f0040e2d..e503c305 100644 --- a/src/screens.css +++ b/src/screens.css @@ -26,6 +26,10 @@ display: flex; justify-content: center; z-index: 12; + /* Account for GUI scaling */ + width: calc(100dvw / var(--guiScale, 1)); + height: calc(100dvh / var(--guiScale, 1)); + overflow: hidden; } .screen-content { From c930365e329aef0c44845ab06aa42266f5a6a16e Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Thu, 18 Sep 2025 07:49:44 +0300 Subject: [PATCH 75/86] fix sometimes inventory player should not be rendered --- src/inventoryWindows.ts | 2 ++ src/react/OverlayModelViewer.tsx | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/inventoryWindows.ts b/src/inventoryWindows.ts index d16fee20..d40260df 100644 --- a/src/inventoryWindows.ts +++ b/src/inventoryWindows.ts @@ -470,6 +470,7 @@ const openWindow = (type: string | undefined, title: string | any = undefined) = const isRightClick = type === 'rightclick' const isLeftClick = type === 'leftclick' if (isLeftClick || isRightClick) { + modelViewerState.model = undefined inv.canvasManager.children[0].showRecipesOrUsages(isLeftClick, item) } } else { @@ -501,6 +502,7 @@ const openWindow = (type: string | undefined, title: string | any = undefined) = if (freeSlot === null) return void bot.creative.setInventorySlot(freeSlot, item) } else { + modelViewerState.model = undefined inv.canvasManager.children[0].showRecipesOrUsages(!isRightclick, mapSlots([item], true)[0]) } } diff --git a/src/react/OverlayModelViewer.tsx b/src/react/OverlayModelViewer.tsx index 24dc836d..0fdeae75 100644 --- a/src/react/OverlayModelViewer.tsx +++ b/src/react/OverlayModelViewer.tsx @@ -6,6 +6,7 @@ import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' import { applySkinToPlayerObject, createPlayerObject, PlayerObjectType } from '../../renderer/viewer/lib/createPlayerObject' import { currentScaling } from '../scaleInterface' +import { activeModalStack } from '../globalState' THREE.ColorManagement.enabled = false @@ -29,6 +30,7 @@ export const modelViewerState = proxy({ modelCustomization?: { [modelUrl: string]: { color?: string, opacity?: number, metalness?: number, roughness?: number } } resetRotationOnReleae?: boolean continiousRender?: boolean + alwaysRender?: boolean } }) globalThis.modelViewerState = modelViewerState @@ -75,6 +77,15 @@ globalThis.getModelViewerValues = () => { } } +subscribe(activeModalStack, () => { + if (!modelViewerState.model || !modelViewerState.model?.alwaysRender) { + return + } + if (activeModalStack.length === 0) { + modelViewerState.model = undefined + } +}) + export default () => { const { model } = useSnapshot(modelViewerState) const containerRef = useRef(null) From 636a7fdb54a2fec622b5b31da4691a632049b5f0 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 19 Sep 2025 04:42:22 +0200 Subject: [PATCH 76/86] feat: improve fog a little (#427) --- renderer/viewer/three/skyboxRenderer.ts | 43 ++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/renderer/viewer/three/skyboxRenderer.ts b/renderer/viewer/three/skyboxRenderer.ts index cd7bd879..fb9edae6 100644 --- a/renderer/viewer/three/skyboxRenderer.ts +++ b/renderer/viewer/three/skyboxRenderer.ts @@ -1,4 +1,5 @@ import * as THREE from 'three' +import { DebugGui } from '../lib/DebugGui' export const DEFAULT_TEMPERATURE = 0.75 @@ -17,11 +18,33 @@ export class SkyboxRenderer { private waterBreathing = false private fogBrightness = 0 private prevFogBrightness = 0 + private readonly fogOrangeness = 0 // Debug property to control sky color orangeness + private readonly distanceFactor = 2.7 + + private readonly brightnessAtPosition = 1 + debugGui: DebugGui constructor (private readonly scene: THREE.Scene, public defaultSkybox: boolean, public initialImage: string | null) { + this.debugGui = new DebugGui('skybox_renderer', this, [ + 'temperature', + 'worldTime', + 'inWater', + 'waterBreathing', + 'fogOrangeness', + 'brightnessAtPosition', + 'distanceFactor' + ], { + brightnessAtPosition: { min: 0, max: 1, step: 0.01 }, + temperature: { min: 0, max: 1, step: 0.01 }, + worldTime: { min: 0, max: 24_000, step: 1 }, + fogOrangeness: { min: -1, max: 1, step: 0.01 }, + distanceFactor: { min: 0, max: 5, step: 0.01 }, + }) + if (!initialImage) { this.createGradientSky() } + // this.debugGui.activate() } async init () { @@ -95,6 +118,7 @@ export class SkyboxRenderer { // Update world time updateTime (timeOfDay: number, partialTicks = 0) { + if (this.debugGui.visible) return this.worldTime = timeOfDay this.partialTicks = partialTicks this.updateSkyColors() @@ -108,12 +132,14 @@ export class SkyboxRenderer { // Update temperature (for biome support) updateTemperature (temperature: number) { + if (this.debugGui.visible) return this.temperature = temperature this.updateSkyColors() } // Update water state updateWaterState (inWater: boolean, waterBreathing: boolean) { + if (this.debugGui.visible) return this.inWater = inWater this.waterBreathing = waterBreathing this.updateSkyColors() @@ -121,6 +147,7 @@ export class SkyboxRenderer { // Update default skybox setting updateDefaultSkybox (defaultSkybox: boolean) { + if (this.debugGui.visible) return this.defaultSkybox = defaultSkybox this.updateSkyColors() } @@ -229,8 +256,15 @@ export class SkyboxRenderer { if (temperature < -1) temperature = -1 if (temperature > 1) temperature = 1 - const hue = 0.622_222_2 - temperature * 0.05 - const saturation = 0.5 + temperature * 0.1 + // Apply debug fog orangeness to hue - positive values make it more orange, negative make it less orange + const baseHue = 0.622_222_2 - temperature * 0.05 + // Orange is around hue 0.08-0.15, so we need to shift from blue-purple (0.62) toward orange + // Use a more dramatic shift and also increase saturation for more noticeable effect + const orangeHue = 0.12 // Orange hue value + const hue = this.fogOrangeness > 0 + ? baseHue + (orangeHue - baseHue) * this.fogOrangeness * 0.8 // Blend toward orange + : baseHue + this.fogOrangeness * 0.1 // Subtle shift for negative values + const saturation = 0.5 + temperature * 0.1 + Math.abs(this.fogOrangeness) * 0.3 // Increase saturation with orangeness const brightness = 1 return this.hsbToRgb(hue, saturation, brightness) @@ -305,8 +339,7 @@ export class SkyboxRenderer { // Update fog brightness with smooth transition this.prevFogBrightness = this.fogBrightness const renderDistance = this.viewDistance / 32 - const brightnessAtPosition = 1 // Could be affected by light level in future - const targetBrightness = brightnessAtPosition * (1 - renderDistance) + renderDistance + const targetBrightness = this.brightnessAtPosition * (1 - renderDistance) + renderDistance this.fogBrightness += (targetBrightness - this.fogBrightness) * 0.1 // Handle water fog @@ -340,7 +373,7 @@ export class SkyboxRenderer { const blue = (fogColor.z + (skyColor.z - fogColor.z) * viewFactor) * clampedBrightness * interpolatedBrightness this.scene.background = new THREE.Color(red, green, blue) - this.scene.fog = new THREE.Fog(new THREE.Color(red, green, blue), 0.0025, viewDistance * 2) + this.scene.fog = new THREE.Fog(new THREE.Color(red, green, blue), 0.0025, viewDistance * this.distanceFactor) ;(this.skyMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color(skyColor.x, skyColor.y, skyColor.z)) ;(this.voidMesh.material as THREE.MeshBasicMaterial).color.set(new THREE.Color( From 3b94889bed40e9c687be52c5ca9a87172c6c6a9d Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 20 Sep 2025 01:57:59 +0200 Subject: [PATCH 77/86] feat: make arrows colorful and metadata (#430) Co-authored-by: Cursor Agent --- renderer/viewer/three/waypointSprite.ts | 36 ++++++++++++++++++++----- renderer/viewer/three/waypoints.ts | 4 ++- src/customChannels.ts | 17 +++++++++++- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/renderer/viewer/three/waypointSprite.ts b/renderer/viewer/three/waypointSprite.ts index 7c8cf1f6..6a30e6db 100644 --- a/renderer/viewer/three/waypointSprite.ts +++ b/renderer/viewer/three/waypointSprite.ts @@ -16,7 +16,7 @@ export const WAYPOINT_CONFIG = { CANVAS_SCALE: 2, ARROW: { enabledDefault: false, - pixelSize: 30, + pixelSize: 50, paddingPx: 50, }, } @@ -50,6 +50,7 @@ export function createWaypointSprite (options: { depthTest?: boolean, // Y offset in world units used by updateScaleWorld only (screen-pixel API ignores this) labelYOffset?: number, + metadata?: any, }): WaypointSprite { const color = options.color ?? 0xFF_00_00 const depthTest = options.depthTest ?? false @@ -131,16 +132,22 @@ export function createWaypointSprite (options: { canvas.height = size const ctx = canvas.getContext('2d')! ctx.clearRect(0, 0, size, size) + + // Draw arrow shape ctx.beginPath() - ctx.moveTo(size * 0.2, size * 0.5) - ctx.lineTo(size * 0.8, size * 0.5) - ctx.lineTo(size * 0.5, size * 0.2) + ctx.moveTo(size * 0.15, size * 0.5) + ctx.lineTo(size * 0.85, size * 0.5) + ctx.lineTo(size * 0.5, size * 0.15) ctx.closePath() - ctx.lineWidth = 4 + + // Use waypoint color for arrow + const colorHex = `#${color.toString(16).padStart(6, '0')}` + ctx.lineWidth = 6 ctx.strokeStyle = 'black' ctx.stroke() - ctx.fillStyle = 'white' + ctx.fillStyle = colorHex ctx.fill() + const texture = new THREE.CanvasTexture(canvas) const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false }) arrowSprite = new THREE.Sprite(material) @@ -169,6 +176,9 @@ export function createWaypointSprite (options: { ensureArrow() if (!arrowSprite) return true + // Check if onlyLeftRight is enabled in metadata + const onlyLeftRight = options.metadata?.onlyLeftRight === true + // Build camera basis using camera.up to respect custom orientations const forward = new THREE.Vector3() camera.getWorldDirection(forward) // camera look direction @@ -213,6 +223,20 @@ export function createWaypointSprite (options: { } } + // Apply onlyLeftRight logic - restrict arrows to left/right edges only + if (onlyLeftRight) { + // Force the arrow to appear only on left or right edges + if (Math.abs(rx) > Math.abs(ry)) { + // Horizontal direction is dominant, keep it + ry = 0 + } else { + // Vertical direction is dominant, but we want only left/right + // So choose left or right based on the sign of rx + rx = rx >= 0 ? 1 : -1 + ry = 0 + } + } + // Place on the rectangle border [-1,1]x[-1,1] const s = Math.max(Math.abs(rx), Math.abs(ry)) || 1 let ndcX = rx / s diff --git a/renderer/viewer/three/waypoints.ts b/renderer/viewer/three/waypoints.ts index cebd779a..256ca6df 100644 --- a/renderer/viewer/three/waypoints.ts +++ b/renderer/viewer/three/waypoints.ts @@ -17,6 +17,7 @@ interface WaypointOptions { color?: number label?: string minDistance?: number + metadata?: any } export class WaypointsRenderer { @@ -71,13 +72,14 @@ export class WaypointsRenderer { this.removeWaypoint(id) const color = options.color ?? 0xFF_00_00 - const { label } = options + const { label, metadata } = options const minDistance = options.minDistance ?? 0 const sprite = createWaypointSprite({ position: new THREE.Vector3(x, y, z), color, label: (label || id), + metadata, }) sprite.enableOffscreenArrow(true) sprite.setArrowParent(this.waypointScene) diff --git a/src/customChannels.ts b/src/customChannels.ts index b566f9dd..506ea776 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -82,15 +82,30 @@ const registerWaypointChannels = () => { { name: 'color', type: 'i32' + }, + { + name: 'metadataJson', + type: ['pstring', { countType: 'i16' }] } ] ] registerChannel('minecraft-web-client:waypoint-add', packetStructure, (data) => { + // Parse metadata if provided + let metadata: any = {} + if (data.metadataJson && data.metadataJson.trim() !== '') { + try { + metadata = JSON.parse(data.metadataJson) + } catch (error) { + console.warn('Failed to parse waypoint metadataJson:', error) + } + } + getThreeJsRendererMethods()?.addWaypoint(data.id, data.x, data.y, data.z, { minDistance: data.minDistance, label: data.label || undefined, - color: data.color || undefined + color: data.color || undefined, + metadata }) }) From 4f421ae45fda892cc364cc60de47e4fc79799eee Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 28 Sep 2025 21:59:00 +0300 Subject: [PATCH 78/86] respect loadPlayerSkins option for inventory skin --- src/watchOptions.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 779aa29f..de7d30d3 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -3,6 +3,7 @@ import { subscribeKey } from 'valtio/utils' import { isMobile } from 'renderer/viewer/lib/simpleUtils' import { WorldDataEmitter } from 'renderer/viewer/lib/worldDataEmitter' +import { setSkinsConfig } from 'renderer/viewer/lib/utils/skins' import { options, watchValue } from './optionsStorage' import { reloadChunks } from './utils' import { miscUiState } from './globalState' @@ -97,6 +98,8 @@ export const watchOptionsAfterViewerInit = () => { appViewer.inWorldRenderingConfig.highlightBlockColor = o.highlightBlockColor appViewer.inWorldRenderingConfig._experimentalSmoothChunkLoading = o.rendererSharedOptions._experimentalSmoothChunkLoading appViewer.inWorldRenderingConfig._renderByChunks = o.rendererSharedOptions._renderByChunks + + setSkinsConfig({ apiEnabled: o.loadPlayerSkins }) }) appViewer.inWorldRenderingConfig.smoothLighting = options.smoothLighting From b239636356c9bb828181cf069c3756c722cebd33 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 28 Sep 2025 22:04:17 +0300 Subject: [PATCH 79/86] feat: add debugServerPacketNames and debugClientPacketNames for quick access of names with intellisense of packets for current protocol. Should be used with `window.inspectPacket` in console --- src/devtools.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/devtools.ts b/src/devtools.ts index 6c47f73d..1f8ef8e8 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -5,6 +5,17 @@ import { WorldRendererThree } from 'renderer/viewer/three/worldrendererThree' import { enable, disable, enabled } from 'debug' import { Vec3 } from 'vec3' +customEvents.on('mineflayerBotCreated', () => { + window.debugServerPacketNames = Object.fromEntries(Object.keys(loadedData.protocol.play.toClient.types).map(name => { + name = name.replace('packet_', '') + return [name, name] + })) + window.debugClientPacketNames = Object.fromEntries(Object.keys(loadedData.protocol.play.toServer.types).map(name => { + name = name.replace('packet_', '') + return [name, name] + })) +}) + window.Vec3 = Vec3 window.cursorBlockRel = (x = 0, y = 0, z = 0) => { const newPos = bot.blockAtCursor(5)?.position.offset(x, y, z) From 05cd560d6b67e287acd6684ffeacc0db4b0b2386 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Mon, 29 Sep 2025 02:01:04 +0300 Subject: [PATCH 80/86] add shadow and directional light for player in inventory (model viewer) --- src/react/OverlayModelViewer.tsx | 54 +++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/src/react/OverlayModelViewer.tsx b/src/react/OverlayModelViewer.tsx index 0fdeae75..e48a2f0b 100644 --- a/src/react/OverlayModelViewer.tsx +++ b/src/react/OverlayModelViewer.tsx @@ -119,11 +119,15 @@ export default () => { modelLoaders.current.set(modelUrl, loader) const onLoad = (object: THREE.Object3D) => { - // Apply customization if available + // Apply customization if available and enable shadows const customization = model?.modelCustomization?.[modelUrl] - if (customization) { - object.traverse((child) => { - if (child instanceof THREE.Mesh && child.material) { + object.traverse((child) => { + if (child instanceof THREE.Mesh) { + // Enable shadow casting and receiving for all meshes + child.castShadow = true + child.receiveShadow = true + + if (child.material && customization) { const material = child.material as THREE.MeshStandardMaterial if (customization.color) { material.color.setHex(parseInt(customization.color.replace('#', ''), 16)) @@ -139,8 +143,8 @@ export default () => { material.roughness = customization.roughness } } - }) - } + } + }) // Center and scale model const box = new THREE.Box3().setFromObject(object) @@ -259,6 +263,12 @@ export default () => { } renderer.setPixelRatio(scale) renderer.setSize(model.positioning.width, model.positioning.height) + + // Enable shadow rendering for depth and realism + renderer.shadowMap.enabled = true + renderer.shadowMap.type = THREE.PCFSoftShadowMap // Soft shadows for better quality + renderer.shadowMap.autoUpdate = true + containerRef.current.appendChild(renderer.domElement) // Setup controls @@ -270,10 +280,30 @@ export default () => { controls.enableDamping = true controls.dampingFactor = 0.05 - // Add ambient light - const ambientLight = new THREE.AmbientLight(0xff_ff_ff, 1) + // Add ambient light for overall illumination + const ambientLight = new THREE.AmbientLight(0xff_ff_ff, 0.4) // Reduced intensity to allow shadows scene.add(ambientLight) + // Add directional light for shadows and depth (similar to Minecraft inventory lighting) + const directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.6) + directionalLight.position.set(2, 2, 2) // Position light from top-right-front + directionalLight.target.position.set(0, 0, 0) // Point towards center of scene + + // Configure shadow properties for optimal quality + directionalLight.castShadow = true + directionalLight.shadow.mapSize.width = 2048 // High resolution shadow map + directionalLight.shadow.mapSize.height = 2048 + directionalLight.shadow.camera.near = 0.1 + directionalLight.shadow.camera.far = 10 + directionalLight.shadow.camera.left = -3 + directionalLight.shadow.camera.right = 3 + directionalLight.shadow.camera.top = 3 + directionalLight.shadow.camera.bottom = -3 + directionalLight.shadow.bias = -0.0001 // Reduce shadow acne + + scene.add(directionalLight) + scene.add(directionalLight.target) + // Cursor following function const updatePlayerLookAt = () => { if (!isFollowingCursor.current || !sceneRef.current?.playerObject) return @@ -342,6 +372,14 @@ export default () => { scale: 1 // Start with base scale, will adjust below }) + // Enable shadows for player object + wrapper.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.castShadow = true + child.receiveShadow = true + } + }) + // Calculate proper scale and positioning for camera view const box = new THREE.Box3().setFromObject(wrapper) const size = box.getSize(new THREE.Vector3()) From f51254d97a9a04be3eb5750d214c59e0c41ffe76 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 30 Sep 2025 07:20:30 +0300 Subject: [PATCH 81/86] fix: dont stop local replay server with keep alive connection error --- src/packetsReplay/replayPackets.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/packetsReplay/replayPackets.ts b/src/packetsReplay/replayPackets.ts index d0d95da8..54b3d652 100644 --- a/src/packetsReplay/replayPackets.ts +++ b/src/packetsReplay/replayPackets.ts @@ -59,6 +59,7 @@ export const startLocalReplayServer = (contents: string) => { const server = createServer({ Server: LocalServer as any, version: header.minecraftVersion, + keepAlive: false, 'online-mode': false }) From a88c8b547044c1dab9c759e56794d614cc41ffa4 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 30 Sep 2025 09:38:37 +0300 Subject: [PATCH 82/86] possible fix for rare edgecase where skins from server were not applied. Cause: renderer due to rare circumnstances could be loaded AFTER gameLoaded which is fired only when starting rendering 3d world. classic no existing data handling issue why not mineflayerBotCreated? because getThreeJsRendererMethods not available at that time so would make things only much complex --- src/entities.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/entities.ts b/src/entities.ts index dcec6143..674f91ef 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -246,22 +246,29 @@ customEvents.on('gameLoaded', () => { } } // even if not found, still record to cache - void getThreeJsRendererMethods()?.updatePlayerSkin(entityId, player.username, player.uuid, skinUrl ?? true, capeUrl) + void getThreeJsRendererMethods()!.updatePlayerSkin(entityId, player.username, player.uuid, skinUrl ?? true, capeUrl) } catch (err) { - console.error('Error decoding player texture:', err) + reportError(new Error('Error applying skin texture:', { cause: err })) } } bot.on('playerJoined', updateSkin) bot.on('playerUpdated', updateSkin) + for (const entity of Object.values(bot.players)) { + updateSkin(entity) + } - bot.on('teamUpdated', (team: Team) => { + const teamUpdated = (team: Team) => { for (const entity of Object.values(bot.entities)) { if (entity.type === 'player' && entity.username && team.members.includes(entity.username) || entity.uuid && team.members.includes(entity.uuid)) { bot.emit('entityUpdate', entity) } } - }) + } + bot.on('teamUpdated', teamUpdated) + for (const team of Object.values(bot.teams)) { + teamUpdated(team) + } const updateEntityNameTags = (team: Team) => { for (const entity of Object.values(bot.entities)) { From 634df8d03dfd90aa978433e39c23376a4116a15d Mon Sep 17 00:00:00 2001 From: Colbster937 Date: Fri, 10 Oct 2025 17:52:06 -0500 Subject: [PATCH 83/86] Add WebMC & WS changes (#431) Co-authored-by: Colbster937 <96893162+colbychittenden@users.noreply.github.com> --- config.json | 4 ++++ src/appConfig.ts | 2 +- src/mineflayer/websocket-core.ts | 7 +++++-- src/react/AddServerOrConnect.tsx | 2 +- src/react/ServersListProvider.tsx | 2 ++ 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/config.json b/config.json index 940fb738..2bfa9cfe 100644 --- a/config.json +++ b/config.json @@ -10,6 +10,10 @@ { "ip": "wss://play.mcraft.fun" }, + { + "ip": "wss://play.webmc.fun", + "name": "WebMC" + }, { "ip": "wss://ws.fuchsmc.net" }, diff --git a/src/appConfig.ts b/src/appConfig.ts index 92fde21a..c29d74e8 100644 --- a/src/appConfig.ts +++ b/src/appConfig.ts @@ -35,7 +35,7 @@ export type AppConfig = { // defaultVersion?: string peerJsServer?: string peerJsServerFallback?: string - promoteServers?: Array<{ ip, description, version? }> + promoteServers?: Array<{ ip, description, name?, version?, }> mapsProvider?: string appParams?: Record // query string params diff --git a/src/mineflayer/websocket-core.ts b/src/mineflayer/websocket-core.ts index 0edd2497..f8163102 100644 --- a/src/mineflayer/websocket-core.ts +++ b/src/mineflayer/websocket-core.ts @@ -15,9 +15,12 @@ class CustomDuplex extends Duplex { } export const getWebsocketStream = async (host: string) => { - const baseProtocol = location.protocol === 'https:' ? 'wss' : host.startsWith('ws://') ? 'ws' : 'wss' + const baseProtocol = host.startsWith('ws://') ? 'ws' : 'wss' const hostClean = host.replace('ws://', '').replace('wss://', '') - const ws = new WebSocket(`${baseProtocol}://${hostClean}`) + const hostURL = new URL(`${baseProtocol}://${hostClean}`) + const hostParams = hostURL.searchParams + hostParams.append('client_mcraft', '') + const ws = new WebSocket(`${baseProtocol}://${hostURL.host}${hostURL.pathname}?${hostParams.toString()}`) const clientDuplex = new CustomDuplex(undefined, data => { ws.send(data) }) diff --git a/src/react/AddServerOrConnect.tsx b/src/react/AddServerOrConnect.tsx index d478b3e7..36fd5264 100644 --- a/src/react/AddServerOrConnect.tsx +++ b/src/react/AddServerOrConnect.tsx @@ -117,7 +117,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ } const displayConnectButton = qsParamIp - const serverExamples = ['example.com:25565', 'play.hypixel.net', 'ws://play.pcm.gg'] + const serverExamples = ['example.com:25565', 'play.hypixel.net', 'ws://play.pcm.gg', 'wss://play.webmc.fun'] // pick random example const example = serverExamples[Math.floor(Math.random() * serverExamples.length)] diff --git a/src/react/ServersListProvider.tsx b/src/react/ServersListProvider.tsx index 75f95d3f..42ef2aaa 100644 --- a/src/react/ServersListProvider.tsx +++ b/src/react/ServersListProvider.tsx @@ -119,6 +119,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL ...serversListProvided, ...(customServersList ? [] : (miscUiState.appConfig?.promoteServers ?? [])).map((server): StoreServerItem => ({ ip: server.ip, + name: server.name, versionOverride: server.version, description: server.description, isRecommended: true @@ -167,6 +168,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL console.log('pingResult.fullInfo.description', pingResult.fullInfo.description) data = { formattedText: pingResult.fullInfo.description, + icon: pingResult.fullInfo.favicon, textNameRight: `ws ${pingResult.latency}ms`, textNameRightGrayed: `${pingResult.fullInfo.players?.online ?? '??'}/${pingResult.fullInfo.players?.max ?? '??'}`, offline: false From e9f91f8ecda1488c636f35f58cc522f459a29f82 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 11 Oct 2025 02:24:51 +0300 Subject: [PATCH 84/86] feat: enable music by default, add slider for controlling its volume --- src/basicSounds.ts | 30 ++++++++++++++++++++---------- src/defaultOptions.ts | 3 ++- src/optionsGuiScheme.tsx | 18 ++++++++++++++++++ src/react/OptionsItems.tsx | 13 +++++++++++-- src/sounds/musicSystem.ts | 4 ++-- 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/basicSounds.ts b/src/basicSounds.ts index 37f8dccd..54af0d35 100644 --- a/src/basicSounds.ts +++ b/src/basicSounds.ts @@ -7,7 +7,12 @@ let audioContext: AudioContext const sounds: Record = {} // Track currently playing sounds and their gain nodes -const activeSounds: Array<{ source: AudioBufferSourceNode; gainNode: GainNode; volumeMultiplier: number }> = [] +const activeSounds: Array<{ + source: AudioBufferSourceNode; + gainNode: GainNode; + volumeMultiplier: number; + isMusic: boolean; +}> = [] window.activeSounds = activeSounds // load as many resources on page load as possible instead on demand as user can disable internet connection after he thinks the page is loaded @@ -43,7 +48,7 @@ export async function loadSound (path: string, contents = path) { } } -export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = options.remoteSoundsLoadTimeout, loop = false) => { +export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = options.remoteSoundsLoadTimeout, loop = false, isMusic = false) => { const soundBuffer = sounds[url] if (!soundBuffer) { const start = Date.now() @@ -51,11 +56,11 @@ export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = option if (cancelled || Date.now() - start > loadTimeout) return } - return playSound(url, soundVolume, loop) + return playSound(url, soundVolume, loop, isMusic) } -export async function playSound (url, soundVolume = 1, loop = false) { - const volume = soundVolume * (options.volume / 100) +export async function playSound (url, soundVolume = 1, loop = false, isMusic = false) { + const volume = soundVolume * (options.volume / 100) * (isMusic ? options.musicVolume / 100 : 1) if (!volume) return @@ -82,7 +87,7 @@ export async function playSound (url, soundVolume = 1, loop = false) { source.start(0) // Add to active sounds - activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume }) + activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume, isMusic }) const callbacks = [] as Array<() => void> source.onended = () => { @@ -110,6 +115,7 @@ export async function playSound (url, soundVolume = 1, loop = false) { console.warn('Failed to stop sound:', err) } }, + gainNode, } } @@ -137,11 +143,11 @@ export function stopSound (url: string) { } } -export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) { +export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number, newMusicVolume: number) { const normalizedVolume = newVolume / 100 - for (const { gainNode, volumeMultiplier } of activeSounds) { + for (const { gainNode, volumeMultiplier, isMusic } of activeSounds) { try { - gainNode.gain.value = normalizedVolume * volumeMultiplier + gainNode.gain.value = normalizedVolume * volumeMultiplier * (isMusic ? newMusicVolume / 100 : 1) } catch (err) { console.warn('Failed to change sound volume:', err) } @@ -149,5 +155,9 @@ export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) { } subscribeKey(options, 'volume', () => { - changeVolumeOfCurrentlyPlayingSounds(options.volume) + changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume) +}) + +subscribeKey(options, 'musicVolume', () => { + changeVolumeOfCurrentlyPlayingSounds(options.volume, options.musicVolume) }) diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index 85ebae17..48c1cfad 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -16,7 +16,8 @@ export const defaultOptions = { chatOpacityOpened: 100, messagesLimit: 200, volume: 50, - enableMusic: false, + enableMusic: true, + musicVolume: 50, // fov: 70, fov: 75, defaultPerspective: 'first_person' as 'first_person' | 'third_person_back' | 'third_person_front', diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index a47c06eb..0cb0fe1e 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -480,6 +480,24 @@ export const guiOptionsScheme: { ], sound: [ { volume: {} }, + { + custom () { + return { + options.musicVolume = value + }} + item={{ + type: 'slider', + id: 'musicVolume', + text: 'Music Volume', + min: 0, + max: 100, + unit: '%', + }} + /> + }, + }, { custom () { return