import * as THREE from 'three' import { OBJLoader } from 'three-stdlib' import huskPng from 'mc-assets/dist/other-textures/latest/entity/zombie/husk.png' import { Vec3 } from 'vec3' import ocelotPng from '../../../../node_modules/mc-assets/dist/other-textures/latest/entity/cat/ocelot.png' import arrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/arrow.png' import spectralArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/spectral_arrow.png' import tippedArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/tipped_arrow.png' import { loadTexture } from '../../lib/utils' import { WorldRendererThree } from '../worldrendererThree' import entities from './entities.json' import { externalModels } from './objModels' import externalTexturesJson from './externalTextures.json' interface ElemFace { dir: [number, number, number] u0: [number, number, number] v0: [number, number, number] u1: [number, number, number] v1: [number, number, number] corners: Array<[number, number, number, number, number]> } interface GeoData { positions: number[] normals: number[] uvs: number[] indices: number[] skinIndices: number[] skinWeights: number[] } interface JsonBone { name: string pivot?: [number, number, number] bind_pose_rotation?: [number, number, number] rotation?: [number, number, number] parent?: string cubes?: JsonCube[] mirror?: boolean } interface JsonCube { origin: [number, number, number] size: [number, number, number] uv: [number, number] inflate?: number rotation?: [number, number, number] } interface JsonModel { texturewidth?: number textureheight?: number bones: JsonBone[] } interface EntityOverrides { textures?: Record rotation?: Record } const elemFaces: Record = { up: { dir: [0, 1, 0], u0: [0, 0, 1], v0: [0, 0, 0], u1: [1, 0, 1], v1: [0, 0, 1], corners: [ [0, 1, 1, 0, 0], [1, 1, 1, 1, 0], [0, 1, 0, 0, 1], [1, 1, 0, 1, 1] ] }, down: { dir: [0, -1, 0], u0: [1, 0, 1], v0: [0, 0, 0], u1: [2, 0, 1], v1: [0, 0, 1], corners: [ [1, 0, 1, 0, 0], [0, 0, 1, 1, 0], [1, 0, 0, 0, 1], [0, 0, 0, 1, 1] ] }, east: { dir: [1, 0, 0], u0: [0, 0, 0], v0: [0, 0, 1], u1: [0, 0, 1], v1: [0, 1, 1], corners: [ [1, 1, 1, 0, 0], [1, 0, 1, 0, 1], [1, 1, 0, 1, 0], [1, 0, 0, 1, 1] ] }, west: { dir: [-1, 0, 0], u0: [1, 0, 1], v0: [0, 0, 1], u1: [1, 0, 2], v1: [0, 1, 1], corners: [ [0, 1, 0, 0, 0], [0, 0, 0, 0, 1], [0, 1, 1, 1, 0], [0, 0, 1, 1, 1] ] }, north: { dir: [0, 0, -1], u0: [0, 0, 1], v0: [0, 0, 1], u1: [1, 0, 1], v1: [0, 1, 1], corners: [ [1, 0, 0, 0, 1], [0, 0, 0, 1, 1], [1, 1, 0, 0, 0], [0, 1, 0, 1, 0] ] }, south: { dir: [0, 0, 1], u0: [1, 0, 2], v0: [0, 0, 1], u1: [2, 0, 2], v1: [0, 1, 1], corners: [ [0, 0, 1, 0, 1], [1, 0, 1, 1, 1], [0, 1, 1, 0, 0], [1, 1, 1, 1, 0] ] } } function dot (a: number[], b: number[]): number { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } function addCube ( attr: GeoData, boneId: number, bone: THREE.Bone, cube: JsonCube, sameTextureForAllFaces = false, texWidth = 64, texHeight = 64, mirror = false, errors: string[] = [] ): void { const cubeRotation = new THREE.Euler(0, 0, 0) if (cube.rotation) { cubeRotation.x = -cube.rotation[0] * Math.PI / 180 cubeRotation.y = -cube.rotation[1] * Math.PI / 180 cubeRotation.z = -cube.rotation[2] * Math.PI / 180 } for (const { dir, corners, u0, v0, u1, v1 } of Object.values(elemFaces)) { const ndx = Math.floor(attr.positions.length / 3) const eastOrWest = dir[0] !== 0 const faceUvs: number[] = [] for (const pos of corners) { let u: number let v: number if (sameTextureForAllFaces) { u = (cube.uv[0] + pos[3] * cube.size[0]) / texWidth v = (cube.uv[1] + pos[4] * cube.size[1]) / texHeight } else { u = (cube.uv[0] + dot(pos[3] ? u1 : u0, cube.size)) / texWidth v = (cube.uv[1] + dot(pos[4] ? v1 : v0, cube.size)) / texHeight } // if (isNaN(u) || isNaN(v)) { // errors.push(`NaN u: ${u}, v: ${v}`) // continue // } // if (u < 0 || u > 1 || v < 0 || v > 1) { // errors.push(`u: ${u}, v: ${v} out of range`) // continue // } const posX = eastOrWest && mirror ? pos[0] ^ 1 : pos[0] const posY = pos[1] const posZ = eastOrWest && mirror ? pos[2] ^ 1 : pos[2] const inflate = cube.inflate ?? 0 let vecPos = new THREE.Vector3( cube.origin[0] + posX * cube.size[0] + (posX ? inflate : -inflate), cube.origin[1] + posY * cube.size[1] + (posY ? inflate : -inflate), cube.origin[2] + posZ * cube.size[2] + (posZ ? inflate : -inflate) ) vecPos = vecPos.applyEuler(cubeRotation) vecPos = vecPos.sub(bone.position) vecPos = vecPos.applyEuler(bone.rotation) vecPos = vecPos.add(bone.position) attr.positions.push(vecPos.x, vecPos.y, vecPos.z) attr.normals.push(dir[0], dir[1], dir[2]) faceUvs.push(u, v) attr.skinIndices.push(boneId, 0, 0, 0) attr.skinWeights.push(1, 0, 0, 0) } if (mirror) { for (let i = 0; i + 1 < corners.length; i += 2) { const faceIndex = i * 2 const tempFaceUvs = faceUvs.slice(faceIndex, faceIndex + 4) faceUvs[faceIndex] = tempFaceUvs[2] faceUvs[faceIndex + 1] = tempFaceUvs[eastOrWest ? 1 : 3] faceUvs[faceIndex + 2] = tempFaceUvs[0] faceUvs[faceIndex + 3] = tempFaceUvs[eastOrWest ? 3 : 1] } } attr.uvs.push(...faceUvs) attr.indices.push(ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3) } } export function getMesh ( worldRenderer: WorldRendererThree | undefined, texture: string, jsonModel: JsonModel, overrides: EntityOverrides = {}, debugFlags: EntityDebugFlags = {} ): THREE.SkinnedMesh { let textureWidth = jsonModel.texturewidth ?? 64 let textureHeight = jsonModel.textureheight ?? 64 let textureOffset: number[] | undefined const useBlockTexture = texture.startsWith('block:') const blocksTexture = worldRenderer?.material.map if (useBlockTexture) { if (!worldRenderer) throw new Error('worldRenderer is required for block textures') const blockName = texture.slice(6) const textureInfo = worldRenderer.resourcesManager.currentResources!.blocksAtlasParser.getTextureInfo(blockName) if (textureInfo) { textureWidth = blocksTexture?.image.width ?? textureWidth textureHeight = blocksTexture?.image.height ?? textureHeight textureOffset = [textureInfo.u, textureInfo.v] } else { console.error(`Unknown block ${blockName}`) } } const bones: Record = {} const geoData: GeoData = { positions: [], normals: [], uvs: [], indices: [], skinIndices: [], skinWeights: [] } let i = 0 for (const jsonBone of jsonModel.bones) { const bone = new THREE.Bone() if (jsonBone.pivot) { bone.position.x = jsonBone.pivot[0] bone.position.y = jsonBone.pivot[1] bone.position.z = jsonBone.pivot[2] } if (jsonBone.bind_pose_rotation) { bone.rotation.x = -jsonBone.bind_pose_rotation[0] * Math.PI / 180 bone.rotation.y = -jsonBone.bind_pose_rotation[1] * Math.PI / 180 bone.rotation.z = -jsonBone.bind_pose_rotation[2] * Math.PI / 180 } else if (jsonBone.rotation) { bone.rotation.x = -jsonBone.rotation[0] * Math.PI / 180 bone.rotation.y = -jsonBone.rotation[1] * Math.PI / 180 bone.rotation.z = -jsonBone.rotation[2] * Math.PI / 180 } if (overrides.rotation?.[jsonBone.name]) { bone.rotation.x -= (overrides.rotation[jsonBone.name].x ?? 0) * Math.PI / 180 bone.rotation.y -= (overrides.rotation[jsonBone.name].y ?? 0) * Math.PI / 180 bone.rotation.z -= (overrides.rotation[jsonBone.name].z ?? 0) * Math.PI / 180 } bone.name = `bone_${jsonBone.name}` bones[jsonBone.name] = bone if (jsonBone.cubes) { for (const cube of jsonBone.cubes) { const errors: string[] = [] addCube(geoData, i, bone, cube, useBlockTexture, textureWidth, textureHeight, jsonBone.mirror, errors) if (errors.length) { debugFlags.errors ??= [] debugFlags.errors.push(...errors.map(error => `Bone ${jsonBone.name}: ${error}`)) } } } i++ } const rootBones: THREE.Object3D[] = [] for (const jsonBone of jsonModel.bones) { if (jsonBone.parent && bones[jsonBone.parent]) { bones[jsonBone.parent].add(bones[jsonBone.name]) } else { rootBones.push(bones[jsonBone.name]) } } const skeleton = new THREE.Skeleton(Object.values(bones)) const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(geoData.positions, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(geoData.normals, 3)) geometry.setAttribute('uv', new THREE.Float32BufferAttribute(geoData.uvs, 2)) geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(geoData.skinIndices, 4)) geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(geoData.skinWeights, 4)) geometry.setIndex(geoData.indices) const material = new THREE.MeshLambertMaterial({ transparent: true, alphaTest: 0.1 }) const mesh = new THREE.SkinnedMesh(geometry, material) mesh.add(...rootBones) mesh.bind(skeleton) mesh.scale.set(1 / 16, 1 / 16, 1 / 16) if (textureOffset) { // todo(memory) dont clone const loadedTexture = blocksTexture!.clone() loadedTexture.offset.set(textureOffset[0], textureOffset[1]) loadedTexture.needsUpdate = true material.map = loadedTexture } else { void loadTexture(texture, loadedTexture => { if (material.map) { // texture is already loaded return } loadedTexture.magFilter = THREE.NearestFilter loadedTexture.minFilter = THREE.NearestFilter loadedTexture.flipY = false loadedTexture.wrapS = THREE.RepeatWrapping loadedTexture.wrapT = THREE.RepeatWrapping material.map = loadedTexture }, () => { // This callback runs after the texture is fully loaded const actualWidth = material.map!.image.width if (actualWidth && textureWidth !== actualWidth) { material.map!.repeat.x = textureWidth / actualWidth } const actualHeight = material.map!.image.height if (actualHeight && textureHeight !== actualHeight) { material.map!.repeat.y = textureHeight / actualHeight } material.needsUpdate = true }) } return mesh } export const rendererSpecialHandled = ['item_frame', 'item', 'player'] type EntityMapping = { pattern: string | RegExp target: string } const temporaryMappings: EntityMapping[] = [ // Exact matches { pattern: 'furnace_minecart', target: 'minecart' }, { pattern: 'spawner_minecart', target: 'minecart' }, { pattern: 'chest_minecart', target: 'minecart' }, { pattern: 'hopper_minecart', target: 'minecart' }, { pattern: 'command_block_minecart', target: 'minecart' }, { pattern: 'tnt_minecart', target: 'minecart' }, { pattern: 'glow_item_frame', target: 'item_frame' }, { pattern: 'glow_squid', target: 'squid' }, { pattern: 'trader_llama', target: 'llama' }, { pattern: 'chest_boat', target: 'boat' }, { pattern: 'spectral_arrow', target: 'arrow' }, { pattern: 'husk', target: 'zombie' }, { pattern: 'zombie_horse', target: 'horse' }, { pattern: 'donkey', target: 'horse' }, { pattern: 'skeleton_horse', target: 'horse' }, { pattern: 'mule', target: 'horse' }, { pattern: 'ocelot', target: 'cat' }, // Regex patterns { pattern: /_minecraft$/, target: 'minecraft' }, { pattern: /_boat$/, target: 'boat' }, { pattern: /_raft$/, target: 'boat' }, { pattern: /_horse$/, target: 'horse' }, { pattern: /_zombie$/, target: 'zombie' }, { pattern: /_arrow$/, target: 'zombie' }, ] function getEntityMapping (type: string): string | undefined { for (const mapping of temporaryMappings) { if (typeof mapping.pattern === 'string') { if (mapping.pattern === type) return mapping.target } else if (mapping.pattern.test(type)) { return mapping.target } } return undefined } const getEntity = (name: string) => { return entities[name] } const scaleEntity: Record = { zombie: 1.85, husk: 1.85, arrow: 0.0025 } const offsetEntity: Record = { zombie: new Vec3(0, 1, 0), husk: new Vec3(0, 1, 0), boat: new Vec3(0, -1, 0), arrow: new Vec3(0, -0.9, 0) } interface EntityGeometry { geometry: Array<{ name: string; [key: string]: any; }>; } export type EntityDebugFlags = { type?: 'obj' | 'bedrock' tempMap?: string textureMap?: boolean errors?: string[] isHardcodedTexture?: boolean } export class EntityMesh { mesh: THREE.Object3D constructor ( version: string, type: string, worldRenderer?: WorldRendererThree, overrides: EntityOverrides = {}, debugFlags: EntityDebugFlags = {} ) { const originalType = type const mappedValue = getEntityMapping(type) if (mappedValue) { type = mappedValue debugFlags.tempMap = mappedValue } if (externalModels[type]) { const objLoader = new OBJLoader() const texturePathMap = { 'zombie_horse': `textures/${version}/entity/horse/horse_zombie.png`, 'husk': huskPng, 'skeleton_horse': `textures/${version}/entity/horse/horse_skeleton.png`, 'donkey': `textures/${version}/entity/horse/donkey.png`, 'mule': `textures/${version}/entity/horse/mule.png`, 'ocelot': ocelotPng, 'arrow': arrowTexture, 'spectral_arrow': spectralArrowTexture, 'tipped_arrow': tippedArrowTexture } const tempTextureMap = texturePathMap[originalType] || texturePathMap[type] if (tempTextureMap) { debugFlags.textureMap = true } const texturePath = tempTextureMap || externalTexturesJson[type] if (externalTexturesJson[type]) { debugFlags.isHardcodedTexture = true } if (!texturePath) throw new Error(`No texture for ${type}`) const texture = new THREE.TextureLoader().load(texturePath) texture.minFilter = THREE.NearestFilter texture.magFilter = THREE.NearestFilter const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, alphaTest: 0.1 }) const obj = objLoader.parse(externalModels[type]) const scale = scaleEntity[originalType] || scaleEntity[type] if (scale) obj.scale.set(scale, scale, scale) const offset = offsetEntity[originalType] if (offset) obj.position.set(offset.x, offset.y, offset.z) obj.traverse((child) => { if (child instanceof THREE.Mesh) { child.material = material // todo if (child.name === 'Head layer') child.visible = false if (child.name === 'Head' && overrides.rotation?.head) { // todo child.rotation.x -= (overrides.rotation.head.x ?? 0) * Math.PI / 180 child.rotation.y -= (overrides.rotation.head.y ?? 0) * Math.PI / 180 child.rotation.z -= (overrides.rotation.head.z ?? 0) * Math.PI / 180 } } }) this.mesh = obj debugFlags.type = 'obj' return } if (originalType === 'arrow') { // overrides.textures = { // 'default': testArrow, // ...overrides.textures, // } } const e = getEntity(type) if (!e) { // if (knownNotHandled.includes(type)) return // throw new Error(`Unknown entity ${type}`) return } this.mesh = new THREE.Object3D() for (const [name, jsonModel] of Object.entries(e.geometry)) { const texture = overrides.textures?.[name] ?? e.textures[name] if (!texture) continue // console.log(JSON.stringify(jsonModel, null, 2)) const mesh = getMesh(worldRenderer, texture.endsWith('.png') || texture.startsWith('data:image/') || texture.startsWith('block:') ? texture : texture + '.png', jsonModel, overrides, debugFlags) mesh.name = `geometry_${name}` this.mesh.add(mesh) } debugFlags.type = 'bedrock' } static getStaticData (name: string): { boneNames: string[] } { name = getEntityMapping(name) || name if (externalModels[name]) { return { boneNames: [] // todo } } const e = getEntity(name) as EntityGeometry if (!e) throw new Error(`Unknown entity ${name}`) return { boneNames: Object.values(e.geometry).flatMap(x => x.name) } } } window.EntityMesh = EntityMesh