UNPKG

three-sbml-guestgd

Version:

threeJS skinned batched mesh

1,758 lines 58.4 kB
import * as THREE from 'three';

const MAX_BONE_ATLAS_COUNT = 10;

const _stateEvent = {
  unitName: "",
  instanceId: 0,
  matrix: new THREE.Matrix4(),
  distance: 0,
  stateName: "",
  distanceState: "",
  animName: "",
  speed: 0,
};

const eventMatrix = new THREE.Matrix4();

class SkinnedBatchMaterial extends THREE.MeshStandardMaterial {
  constructor({ maps, unitsData, animLodDistance, useAO = false, ...options }) {
    const newDefines = {
      USE_NORMAL_MAP: !!maps.normalMapsArray,
      USE_TANGENT: !!maps.normalMapsArray,
      USE_ROUGHNESS_MAP: !!maps.ormMapsArray,
      USE_METALNESS_MAP: !!maps.ormMapsArray,
      USE_AO_MAP: !!useAO,
      ...(options.defines || {}),
    };

    super({
      ...options,
      defines: newDefines,
    });

    this.batchedMesh = null;

    this.textures = { ...maps };
    this.unitsData = unitsData;
    this.animLodDistance = animLodDistance;
    this.useAO = useAO;

    this.instanceManageTexture = null;
    this.instanceManageArr = null;

    this.materialData = {};

    this.frameEvents = {};

    this._unitsSetup(this.unitsData);

    this._patchMaterial();
    this._createInstancesManageTexture(unitsData);

    this._transitionEvents = {};
  }
  // ==============================================
  //              PRIVATE METHODS
  // ==============================================
  _unitsSetup(unitsData) {
    Object.entries(unitsData).forEach(([name, data], unitIndex) => {
      const { rawMatrices, boneInverses, bonesAmount } = data;

      this._prepareMatrices(
        name,
        rawMatrices,
        boneInverses,
        bonesAmount,
        unitIndex
      );
    });
  }
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  _prepareMatrices(name, rawMatrices, boneInverses, bonesAmount, unitIndex) {
    const dataTextureWidth = bonesAmount * 4;
    const framesAmount = rawMatrices.length / (dataTextureWidth * 4);

    const finalMatricesArr = new Float32Array(
      dataTextureWidth * 4 * framesAmount
    );

    const tmpM = new THREE.Matrix4();
    const tmpI = new THREE.Matrix4();

    for (let f = 0; f < framesAmount; f++) {
      for (let b = 0; b < bonesAmount; b++) {
        const srcOffset = (f * bonesAmount + b) * 16;
        tmpM.fromArray(rawMatrices, srcOffset);
        tmpI.fromArray(boneInverses[b].elements, 0);
        tmpM.multiplyMatrices(tmpM, tmpI);
        tmpM.toArray(finalMatricesArr, srcOffset);
      }
    }

    this._createBoneTextures(
      name,
      finalMatricesArr,
      dataTextureWidth,
      framesAmount,
      unitIndex
    );
  }
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  _createBoneTextures(
    name,
    finalMatricesArr,
    dataTextureWidth,
    framesAmount,
    unitIndex
  ) {
    this.materialData[name] = {};
    const unitFinalData = this.materialData[name];

    const boneAtlas = new THREE.DataTexture(
      finalMatricesArr,
      dataTextureWidth,
      framesAmount,
      THREE.RGBAFormat,
      THREE.FloatType
    );
    boneAtlas.needsUpdate = true;
    boneAtlas.flipY = false;
    boneAtlas.minFilter = THREE.NearestFilter;
    boneAtlas.magFilter = THREE.NearestFilter;
    boneAtlas.generateMipmaps = false;

    unitFinalData.boneAtlas = boneAtlas;

    unitFinalData.boneAtlasArr = finalMatricesArr;

    unitFinalData.unitIndex = unitIndex;
  }
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  _createInstancesManageTexture(unitsData) {
    let totalInstancesAllUnits = 0;

    Object.values(unitsData).forEach((unit) => {
      totalInstancesAllUnits += unit.instancesAmount;
    });

    const instanceManageArr = new Float32Array(totalInstancesAllUnits * 4);

    const instanceManageTexture = new THREE.DataTexture(
      instanceManageArr,
      1,
      totalInstancesAllUnits,
      THREE.RGBAFormat,
      THREE.FloatType
    );
    instanceManageTexture.needsUpdate = true;
    instanceManageTexture.flipY = false;
    instanceManageTexture.minFilter = THREE.NearestFilter;
    instanceManageTexture.magFilter = THREE.NearestFilter;
    instanceManageTexture.generateMipmaps = false;

    this.instanceManageTexture = instanceManageTexture;
    this.instanceManageArr = instanceManageArr;
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  _triggerTransitionEvent(unitName, animName, frame, callback) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    if (!this.materialData[unitName].animations?.[animName]) {
      console.warn(`Animation ${animName} not found for unit ${unitName}`);
      return;
    }

    const anim = this.materialData[unitName].animations[animName];
    const clampedFrame = Math.min(Math.max(0, frame), anim.frameCount - 2);

    // Create private event key with instance-specific prefix
    const eventKey = `_transition|${unitName}|${animName}|${clampedFrame}`;
    this._transitionEvents[eventKey] = callback;
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  _removeTransitionEvent(unitName, animName, frame) {
    const eventKey = `_transition|${unitName}|${animName}|${frame}`;
    delete this._transitionEvents[eventKey];
  }

  // ==============================================
  //              MAIN SHADER
  // ==============================================

  _patchMaterial() {
    const assignBoneAtlas = (shader, index, name) => {
      const atlas = boneAtlases[index];
      if (!atlas?.image) return;

      shader.uniforms[name] = { value: atlas };
      shader.uniforms[`${name}Size`] = {
        value: new THREE.Vector2(atlas.image.width, atlas.image.height),
      };
    };

    const unitsUsedAmount = Math.min(
      Object.keys(this.materialData).length,
      MAX_BONE_ATLAS_COUNT
    );
    const boneAtlases = Object.values(this.materialData)
      .slice(0, unitsUsedAmount)
      .map((item) => item.boneAtlas)
      .filter((atlas) => atlas?.image);

    this.onBeforeCompile = (shader) => {
      Object.assign(shader.defines, this.defines);

      // Declare static uniforms
      let uniformDeclarations = `
        uniform sampler2D instanceManageTexture;
        uniform vec2 animLodDistance;
        
        attribute vec4 skinIndex;
        attribute vec4 skinWeight;
        attribute highp float unitIndex;
        attribute float instanceIndex;
        attribute float mapIndex;
        attribute float instancesAmount;
        varying float vMapIndex;
        varying vec2 vUv;
      `;

      // Generate texture and size uniform declarations and assignments
      for (let i = 0; i < unitsUsedAmount; i++) {
        const name = `boneAtlas${i + 1}`;
        assignBoneAtlas(shader, i, name);

        uniformDeclarations += `
          uniform sampler2D ${name};
          uniform vec2 ${name}Size;
        `;
      }

      // Generate getBoneMatrix function dynamically
      let getBoneMatrixFunction = `
        mat4 getBoneMatrix(float boneIdx, float frame, float unitIndex) {
          vec2 size;
          vec4 v0, v1, v2, v3;
      `;

      for (let i = 0; i < unitsUsedAmount; i++) {
        const condition =
          i === 0 ? `if (unitIndex < 0.5)` : `else if (unitIndex < ${i + 0.5})`;

        const name = `boneAtlas${i + 1}`;

        getBoneMatrixFunction += `
          ${condition} {                
            size = ${name}Size;
            float x = boneIdx * 4.0 + 0.5;
            float y = frame + 0.5;
            vec2 uv = vec2(x / size.x, y / size.y);
            v0 = texture(${name}, uv);
            v1 = texture(${name}, uv + vec2(1.0 / size.x, 0.0));
            v2 = texture(${name}, uv + vec2(2.0 / size.x, 0.0));
            v3 = texture(${name}, uv + vec2(3.0 / size.x, 0.0));
          }
        `;
      }

      getBoneMatrixFunction += `
        return mat4(v0, v1, v2, v3);
        }
      `;

      // Skinning functions
      const skinningFunctions = `
        vec4 skinVertexClose(vec4 pos, float frame, float nextFrame, float mixFactor, float unitIndex) {
            mat4 skinMatrixCurrent =
                getBoneMatrix(skinIndex.x, frame, unitIndex) * skinWeight.x +
                getBoneMatrix(skinIndex.y, frame, unitIndex) * skinWeight.y +
                getBoneMatrix(skinIndex.z, frame, unitIndex) * skinWeight.z +
                getBoneMatrix(skinIndex.w, frame, unitIndex) * skinWeight.w;

            mat4 skinMatrixNext =
                getBoneMatrix(skinIndex.x, nextFrame, unitIndex) * skinWeight.x +
                getBoneMatrix(skinIndex.y, nextFrame, unitIndex) * skinWeight.y +
                getBoneMatrix(skinIndex.z, nextFrame, unitIndex) * skinWeight.z +
                getBoneMatrix(skinIndex.w, nextFrame, unitIndex) * skinWeight.w;

            mat4 skinMatrix = mat4(
              mix(skinMatrixCurrent[0], skinMatrixNext[0], mixFactor),
              mix(skinMatrixCurrent[1], skinMatrixNext[1], mixFactor),
              mix(skinMatrixCurrent[2], skinMatrixNext[2], mixFactor),
              mix(skinMatrixCurrent[3], skinMatrixNext[3], mixFactor)
            );
            return skinMatrix * pos;
        }

        vec4 skinVertexMiddle(vec4 pos, float frame, float nextFrame, float mixFactor, float unitIndex) {
            // --- pick the two largest weights (and their indices) ----------
            vec4 w   = skinWeight;
            vec4 idx = skinIndex;

            // bubble-sort the first three positions to bring the two biggest to .x and .y
            if (w.y > w.x) { float tw = w.x;  w.x  = w.y;  w.y  = tw;
                            float ti = idx.x; idx.x = idx.y; idx.y = ti; }
            if (w.z > w.y) { float tw = w.y;  w.y  = w.z;  w.z  = tw;
                            float ti = idx.y; idx.y = idx.z; idx.z = ti; }
            if (w.y > w.x) { float tw = w.x;  w.x  = w.y;  w.y  = tw;
                            float ti = idx.x; idx.x = idx.y; idx.y = ti; }

            float bone0 = idx.x;
            float bone1 = idx.y;
            float w0    = w.x;
            float w1    = w.y;

            // --- fetch matrices for both bones -----------------------------
            mat4 m0c = getBoneMatrix(bone0, frame,     unitIndex);
            mat4 m0n = getBoneMatrix(bone0, nextFrame, unitIndex);
            mat4 m1c = getBoneMatrix(bone1, frame,     unitIndex);
            mat4 m1n = getBoneMatrix(bone1, nextFrame, unitIndex);

            mat4 skinMatrixCurrent = m0c * w0 + m1c * w1;
            mat4 skinMatrixNext    = m0n * w0 + m1n * w1;

            mat4 skinMatrix = mat4(
                mix(skinMatrixCurrent[0], skinMatrixNext[0], mixFactor),
                mix(skinMatrixCurrent[1], skinMatrixNext[1], mixFactor),
                mix(skinMatrixCurrent[2], skinMatrixNext[2], mixFactor),
                mix(skinMatrixCurrent[3], skinMatrixNext[3], mixFactor)
            );
            return skinMatrix * pos;
        }

        vec4 skinVertexFar(vec4 pos, float frame, float unitIndex) {
            // strongest bone only
            float boneIdx = skinIndex.x;
            if (skinWeight.y > skinWeight.x) boneIdx = skinIndex.y;
            if (skinWeight.z > max(skinWeight.x, skinWeight.y)) boneIdx = skinIndex.z;
            if (skinWeight.w > max(max(skinWeight.x, skinWeight.y), skinWeight.z)) boneIdx = skinIndex.w;

            mat4 skinMatrix = getBoneMatrix(boneIdx, frame, unitIndex);
            return skinMatrix * pos;
        }

        vec3 getInstanceData(float index, float textureHeight) {
            // Since texture width is 4 (RGBA), we sample at x=0.5 (center of first texel)
            // and move down rows based on instanceIndex
            vec4 data = texture2D(instanceManageTexture, vec2(0.5, ((index) + 0.5) / textureHeight));
            // Assuming data.r = frame, data.g = nextFrame, data.b = mixFactor
            return vec3(data.r, data.g, data.b);
        }
      `;

      // Final vertex shader
      shader.vertexShader =
        uniformDeclarations +
        getBoneMatrixFunction +
        skinningFunctions +
        shader.vertexShader;

      shader.uniforms.instanceManageTexture = {
        value: this.instanceManageTexture,
      };
      shader.uniforms.animLodDistance = {
        value: this.animLodDistance || new THREE.Vector2(2000, 5000),
      };
      shader.uniforms.maps = { value: this.textures.mapsArray };
      shader.uniforms.normalMaps = { value: this.textures.normalMapsArray };
      shader.uniforms.ormMapsArray = { value: this.textures.ormMapsArray };
      shader.uniforms.normalScale = { value: new THREE.Vector2(1.0, 1.0) };
      shader.uniforms.aoMapIntensity = { value: 1.0 };

      shader.vertexShader = shader.vertexShader.replace(
        `#include <project_vertex>`,
        `
        vec4 distanceCheckPos = vec4(transformed, 1.0);
        
        #ifdef USE_BATCHING
          distanceCheckPos = batchingMatrix * distanceCheckPos;
        #endif

        #ifdef USE_INSTANCING
          distanceCheckPos = instanceMatrix * distanceCheckPos;
        #endif
        
        distanceCheckPos = modelMatrix * distanceCheckPos;

        vec3 delta = distanceCheckPos.xyz - cameraPosition;           
        float dist2 = dot(delta, delta);                              
        float lodClose  = animLodDistance.x * animLodDistance.x;              
        float lodFar  = animLodDistance.y * animLodDistance.y;              
        
        // Get frame data from texture
        float finalInstanceIndex = unitIndex * instancesAmount + instanceIndex;
        float textureHeight = float(textureSize(instanceManageTexture, 0).y);
        vec3 instanceData = getInstanceData(finalInstanceIndex, textureHeight);
        
        if (dist2 >= lodFar) {
            // far – 1 bone, no interpolation
            transformed = skinVertexFar(vec4(transformed, 1.0), instanceData.x, unitIndex).xyz;
        } else if (dist2 >= lodClose) {
            // mid – 2 bones, interpolated
            transformed = skinVertexMiddle(vec4(transformed, 1.0), instanceData.x, instanceData.y, instanceData.z, unitIndex).xyz;
        } else {
            // close – 4 bones, interpolated
            transformed = skinVertexClose(vec4(transformed, 1.0), instanceData.x, instanceData.y, instanceData.z, unitIndex).xyz;
        }

        vUv = uv;
        vMapIndex = mapIndex;

        #include <project_vertex> 
      `
      );

      shader.fragmentShader = shader.fragmentShader.replace(
        `#include <map_pars_fragment>`,
        `
         uniform sampler2DArray maps;
         uniform sampler2DArray normalMaps;
         uniform sampler2DArray ormMapsArray;

         uniform vec2 normalScale;
         uniform float aoMapIntensity;

         varying float vMapIndex;
         varying vec2 vUv;
        `
      );

      shader.fragmentShader = shader.fragmentShader.replace(
        `#include <map_fragment>`,
        `
         diffuseColor *= texture(maps, vec3(vUv.x, vUv.y, vMapIndex));
        `
      );

      shader.fragmentShader = shader.fragmentShader.replace(
        `#include <normal_fragment_begin>`,
        `
        float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;

        vec3 normal = normalize( vNormal );

        #ifdef DOUBLE_SIDED
          normal *= faceDirection;
        #endif

        #ifdef USE_NORMAL_MAP 
          mat3 tbn = mat3( normalize( vTangent ), normalize( vBitangent ), normal );

          #if defined( DOUBLE_SIDED ) && ! defined( FLAT_SHADED )

            tbn[0] *= faceDirection;
            tbn[1] *= faceDirection;

          #endif
        #endif

        vec3 nonPerturbedNormal = normal;
        `
      );

      shader.fragmentShader = shader.fragmentShader.replace(
        `#include <normal_fragment_maps>`,
        `
        #ifdef USE_NORMAL_MAP 
          vec3 mapN = texture( normalMaps, vec3(vUv.x, vUv.y, vMapIndex)).xyz * 2.0 - 1.0;
          mapN.xy *= normalScale;

          normal = normalize( tbn * mapN );
        #endif
        `
      );

      shader.fragmentShader = shader.fragmentShader.replace(
        `#include <roughnessmap_fragment>`,
        `
        float roughnessFactor = roughness;
        float metalnessFactor = metalness;

        #if defined(USE_ROUGHNESS_MAP) || defined(USE_METALNESS_MAP)
          vec4 texelOrm = texture(ormMapsArray, vec3(vUv.x, vUv.y, vMapIndex));

          // reads channel R - ao, G- rough, B - metal, compatible with a combined OcclusionRoughnessMetallic (RGB) texture
          roughnessFactor *= texelOrm.g;
          metalnessFactor *= texelOrm.b;
        #endif
        `
      );

      shader.fragmentShader = shader.fragmentShader.replace(
        `#include <metalnessmap_fragment>`,
        `
        `
      );

      shader.fragmentShader = shader.fragmentShader.replace(
        `#include <aomap_fragment>`,
        `
        #if defined(USE_AO_MAP) || defined(USE_ROUGHNESS_MAP) 
          float ambientOcclusion = ( texelOrm.r - 1.0 ) * aoMapIntensity + 1.0;
          // float ambientOcclusion = ( texture(ormMapsArray, vec3(vUv.x, vUv.y, vMapIndex)).r - 1.0 ) * aoMapIntensity + 1.0;
          reflectedLight.indirectDiffuse *= ambientOcclusion;
        #endif     
        `
      );
    };
  }
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */

  // ==============================================
  //              BASIC METHODS
  // ==============================================
  setAnimationFrames(
    unitName,
    animName,
    startFrame,
    endFrame,
    fps = 30,
    transit = false
  ) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    if (!this.materialData[unitName].animations) {
      this.materialData[unitName].animations = {};
    }

    const frameCount = Math.abs(endFrame - startFrame) + 1;
    const duration = frameCount / fps;

    this.materialData[unitName].animations[animName] = {
      startFrame,
      endFrame,
      fps,
      frameCount,
      duration,
      isTransit: transit,
      isReversed: startFrame > endFrame,
      transitions: {},
    };
  }
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  setAnimationTransitions(unitName, animName, transitions = {}) {
    if (!this.materialData[unitName]?.animations?.[animName]) {
      console.warn(
        !this.materialData[unitName]
          ? `Unit ${unitName} not found`
          : "Set animation frames first!"
      );
      return;
    }

    Object.assign(
      this.materialData[unitName].animations[animName].transitions,
      transitions
    );
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  playAnimation(unitName, localInstanceId, animName, mode = "loop", speed = 1) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    if (!["loop", "once", "pingpong"].includes(mode)) {
      console.warn(`Invalid animation mode: ${mode}`);
      return;
    }

    const anim = this.materialData[unitName].animations?.[animName];
    if (!anim) {
      console.warn(`Animation ${animName} not found for unit ${unitName}`);
      return;
    }

    const instanceDataTexture = this.instanceManageTexture;
    const instanceDataArr = this.instanceManageArr;

    const unitOffset =
      this.materialData[unitName].unitIndex *
      this.unitsData[unitName].instancesAmount *
      4;
    const instanceOffset = localInstanceId * 4;
    const finalOffset = unitOffset + instanceOffset;

    // Initialize frames based on direction
    if (anim.isReversed) {
      instanceDataArr[finalOffset] = anim.startFrame;
      instanceDataArr[finalOffset + 1] = anim.startFrame - 1;
    } else {
      instanceDataArr[finalOffset] = anim.startFrame;
      instanceDataArr[finalOffset + 1] = anim.startFrame + 1;
    }
    instanceDataArr[finalOffset + 2] = 0;
    instanceDataArr[finalOffset + 3] = speed;

    // Store animation metadata
    if (!this.materialData[unitName].instanceStates) {
      this.materialData[unitName].instanceStates = {};
    }

    this.materialData[unitName].instanceStates[localInstanceId] ??= {};

    const state = this.materialData[unitName].instanceStates[localInstanceId];

    Object.assign(state, {
      animName,
      mode,
      speed,
      currentTime: 0,
      currentFrame: anim.startFrame,
      nextFrame: anim.isReversed ? anim.startFrame - 1 : anim.startFrame + 1,
      mixFactor: 0,
    });

    instanceDataTexture.needsUpdate = true;
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  playAnimationBatched(
    unitName,
    startInstanceId,
    endInstanceId,
    animName,
    mode = "loop",
    speed = 1
  ) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    if (!["loop", "once", "pingpong"].includes(mode)) {
      console.warn(`Invalid animation mode: ${mode}`);
      return;
    }

    const anim = this.materialData[unitName].animations?.[animName];
    if (!anim) {
      console.warn(`Animation ${animName} not found for unit ${unitName}`);
      return;
    }

    // Validate instance range
    const maxInstances = this.unitsData[unitName].instancesAmount;
    if (startInstanceId < 0 || startInstanceId > endInstanceId) {
      console.warn(
        `Invalid instance range: ${startInstanceId}-${endInstanceId}`
      );
      return;
    }

    let lastInstance = endInstanceId;

    if (endInstanceId >= maxInstances) lastInstance = maxInstances - 1;

    // Apply animation to each instance in the range
    for (let i = startInstanceId; i <= lastInstance; i++) {
      this.playAnimation(unitName, i, animName, mode, speed);
    }
  }
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  stopAnimation(unitName, localInstanceId) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    const instanceDataTexture = this.instanceManageTexture;
    const instanceDataArr = this.instanceManageArr;

    const unitOffset =
      this.materialData[unitName].unitIndex *
      this.unitsData[unitName].instancesAmount *
      4;
    const instanceOffset = localInstanceId * 4;
    const finalOffset = unitOffset + instanceOffset;

    // Reset animation data in the texture
    instanceDataArr[finalOffset] = 0; // current frame
    instanceDataArr[finalOffset + 1] = 0; // next frame
    instanceDataArr[finalOffset + 2] = 0; // mix factor
    instanceDataArr[finalOffset + 3] = 0; // speed

    // Remove the instance state if it exists
    if (this.materialData[unitName].instanceStates) {
      delete this.materialData[unitName].instanceStates[localInstanceId];
    }

    instanceDataTexture.needsUpdate = true;
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  stopAnimationBatched(unitName, startInstanceId, endInstanceId) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    // Validate instance range
    const maxInstances = this.unitsData[unitName].instancesAmount;
    if (startInstanceId < 0 || startInstanceId > endInstanceId) {
      console.warn(
        `Invalid instance range: ${startInstanceId}-${endInstanceId}`
      );
      return;
    }

    let lastInstance = endInstanceId;
    if (endInstanceId >= maxInstances) lastInstance = maxInstances - 1;

    // Stop animation for each instance in the range
    for (let i = startInstanceId; i <= lastInstance; i++) {
      this.stopAnimation(unitName, i);
    }
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  pauseAnimation(unitName, localInstanceId) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    // Get the current animation state
    const state = this.materialData[unitName].instanceStates?.[localInstanceId];
    if (!state) {
      console.warn(`No active animation for instance ${localInstanceId}`);
      return;
    }

    // Set speed to 0 while preserving all other state
    const instanceDataTexture = this.instanceManageTexture;
    const instanceDataArr = this.instanceManageArr;

    const unitOffset =
      this.materialData[unitName].unitIndex *
      this.unitsData[unitName].instancesAmount *
      4;
    const instanceOffset = localInstanceId * 4;
    const finalOffset = unitOffset + instanceOffset;

    // Keep current frame data but set speed to 0
    instanceDataArr[finalOffset + 3] = 0;

    // Update the instance state
    state.speed = 0;

    // Update the texture
    instanceDataTexture.needsUpdate = true;
  }
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  resumeAnimation(unitName, localInstanceId, newSpeed = 1) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    // Get the current animation state
    const state = this.materialData[unitName].instanceStates?.[localInstanceId];
    if (!state) {
      console.warn(`No paused animation for instance ${localInstanceId}`);
      return;
    }

    const instanceDataTexture = this.instanceManageTexture;
    const instanceDataArr = this.instanceManageArr;

    const unitOffset =
      this.materialData[unitName].unitIndex *
      this.unitsData[unitName].instancesAmount *
      4;
    const instanceOffset = localInstanceId * 4;
    const finalOffset = unitOffset + instanceOffset;

    // Restore speed
    instanceDataArr[finalOffset + 3] = newSpeed;

    // Update the instance state
    state.speed = newSpeed;

    // Update the texture
    instanceDataTexture.needsUpdate = true;
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  isPaused(unitName, localInstanceId) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return false;
    }

    const state = this.materialData[unitName].instanceStates?.[localInstanceId];
    if (!state) return false;

    // Speed 0 = paused
    return state.speed === 0 && !!state.animName;
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  isStopped(unitName, localInstanceId) {
    if (!this.materialData[unitName]) return true;

    const unitOffset =
      this.materialData[unitName].unitIndex *
      this.unitsData[unitName].instancesAmount *
      4;
    const instanceOffset = localInstanceId * 4;
    const finalOffset = unitOffset + instanceOffset;

    const arr = this.instanceManageArr;
    // All animation fields zeroed out ⇒ stopped
    return (
      arr[finalOffset] === 0 &&
      arr[finalOffset + 1] === 0 &&
      arr[finalOffset + 2] === 0 &&
      arr[finalOffset + 3] === 0
    );
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  isPlaying(unitName, localInstanceId) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return false;
    }

    const state = this.materialData[unitName].instanceStates?.[localInstanceId];
    return !!(state && state.animName && state.speed > 0);
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  transitionToAnimation(
    unitName,
    localInstanceId,
    targetAnimName,
    targetAnimMode = "loop",
    targetAnimSpeed = 1.0,
    transitionClipName = null,
    transitionClipSpeed = 1.0,
    onComplete = null
  ) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    if (!transitionClipName || !targetAnimName) return;

    const currentAnim =
      this.materialData[unitName].instanceStates?.[localInstanceId]?.animName;

    if (currentAnim === targetAnimName) return;

    const currentLastFrame =
      this.materialData[unitName].animations[currentAnim]?.frameCount - 2;

    const transitionLastFrame = transitionClipName
      ? this.materialData[unitName].animations[transitionClipName]?.frameCount -
        2
      : 0;

    if (currentAnim) {
      this._triggerTransitionEvent(
        unitName,
        currentAnim,
        currentLastFrame,
        (e) => {
          this.pauseAnimation(e.unitName, e.instanceId);

          this._triggerTransitionEvent(
            e.unitName,
            transitionClipName,
            transitionLastFrame,
            (transitionEvent) => {
              this.playAnimation(
                transitionEvent.unitName,
                transitionEvent.instanceId,
                targetAnimName,
                targetAnimMode,
                targetAnimSpeed
              );
              if (onComplete)
                onComplete({
                  unitName: transitionEvent.unitName,
                  instanceId: transitionEvent.instanceId,
                  sourceAnimation: currentAnim,
                  targetAnimation: targetAnimName,
                  transitionAnimation: transitionClipName,
                  timestamp: performance.now(),
                });
            }
          );

          this.playAnimation(
            e.unitName,
            e.instanceId,
            transitionClipName,
            "once",
            transitionClipSpeed
          );
        }
      );
    }
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  createEvent(unitName, animName, frame, callback) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    if (!this.materialData[unitName].animations?.[animName]) {
      console.warn(`Animation ${animName} not found for unit ${unitName}`);
      return;
    }

    const anim = this.materialData[unitName].animations[animName];

    // Clamp the frame number to valid range
    const clampedFrame = Math.min(Math.max(0, frame), anim.frameCount - 2);

    // Create event key and store callback
    const eventKey = `${unitName}|${animName}|${clampedFrame}`;
    this.frameEvents[eventKey] = callback;
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  removeEvent(unitName, animName, frame) {
    const eventKey = `${unitName}|${animName}|${frame}`;
    delete this.frameEvents[eventKey];
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  getInstanceAnimationData(unitName, instanceIndex) {
    if (
      !this.materialData ||
      !this.materialData[unitName] ||
      !this.materialData[unitName].instanceStates ||
      !(instanceIndex in this.materialData[unitName].instanceStates)
    )
      return;

    return this.materialData[unitName].instanceStates[instanceIndex].animName;
  }
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  setBatchedMesh(batchedMesh) {
    if (!batchedMesh) return;

    this.batchedMesh = batchedMesh;
  }

  // ==============================================
  //              UPDATE LOOP METHOD
  // ==============================================
  updateAnimations(delta) {
    const batchedMesh = this.batchedMesh;
    const instanceDataTexture = this.instanceManageTexture;
    const instanceDataArr = this.instanceManageArr;

    Object.entries(this.materialData).forEach(([unitName, unitData]) => {
      if (!unitData.instanceStates) return;

      const unitOffset =
        unitData.unitIndex * this.unitsData[unitName].instancesAmount * 4;

      Object.entries(unitData.instanceStates).forEach(([instanceId, state]) => {
        const instanceOffset = parseInt(instanceId) * 4;
        const finalOffset = unitOffset + instanceOffset;

        if (!state) return;

        // Normal animation update
        const anim = unitData.animations[state.animName];
        if (!anim) return;

        // Store previous time for event checking
        const prevTime = state.currentTime;
        state.currentTime += delta * state.speed;

        // Calculate current frame based on animation mode
        let relativeFrame, framePosition;
        if (state.mode === "pingpong") {
          const cycleDuration = anim.duration * 2;
          state.currentTime = state.currentTime % cycleDuration;
          const t =
            state.currentTime < anim.duration
              ? state.currentTime / anim.duration
              : 2 - state.currentTime / anim.duration;
          framePosition = t * (anim.frameCount - 1);
        } else {
          if (state.mode === "loop") {
            state.currentTime = state.currentTime % anim.duration;
          } else if (state.mode === "once") {
            state.currentTime = Math.min(state.currentTime, anim.duration);
          }
          framePosition =
            (state.currentTime / anim.duration) * (anim.frameCount - 1);
        }

        // Handle frame calculation based on direction
        if (anim.isReversed) {
          state.currentFrame =
            anim.startFrame -
            Math.min(Math.floor(framePosition), anim.frameCount - 1);
          state.nextFrame =
            anim.startFrame -
            Math.min(Math.ceil(framePosition), anim.frameCount - 1);
        } else {
          state.currentFrame =
            anim.startFrame +
            Math.min(Math.floor(framePosition), anim.frameCount - 1);
          state.nextFrame =
            anim.startFrame +
            Math.min(Math.ceil(framePosition), anim.frameCount - 1);
        }
        state.mixFactor = framePosition - Math.floor(framePosition);

        // Check for frame events
        const prevRelativeFrame = Math.floor(
          (prevTime / anim.duration) * (anim.frameCount - 1)
        );

        relativeFrame = Math.floor(framePosition);

        if (relativeFrame >= anim.frameCount) {
          relativeFrame = anim.frameCount - 1;
        }

        const matrixArray = batchedMesh?.matrices?.[unitName];
        const offset = instanceId * 16;

        if (prevRelativeFrame !== relativeFrame) {
          const eventKey = `${unitName}|${state.animName}|${relativeFrame}`;
          if (this.frameEvents[eventKey]) {
            this.frameEvents[eventKey]({
              unitName,
              instanceId: parseInt(instanceId),
              animName: state.animName,
              frame: relativeFrame,
              absoluteFrame: anim.isReversed
                ? anim.startFrame - relativeFrame
                : anim.startFrame + relativeFrame,
              framesAmount: anim.frameCount,
              lastFrame: anim.frameCount - 1,
              matrix: eventMatrix.fromArray(matrixArray, offset) || null,
              distance:
                batchedMesh?.lodInfo[unitName].unitDist[instanceId] || null,
            });
          }

          const privateEventKey = `_transition|${unitName}|${state.animName}|${relativeFrame}`;
          if (this._transitionEvents[privateEventKey]) {
            this._transitionEvents[privateEventKey]({
              unitName,
              instanceId: parseInt(instanceId),
              animName: state.animName,
              frame: relativeFrame,
              absoluteFrame: anim.isReversed
                ? anim.startFrame - relativeFrame
                : anim.startFrame + relativeFrame,
              framesAmount: anim.frameCount,
              lastFrame: anim.frameCount - 1,
              matrix: eventMatrix.fromArray(matrixArray, offset) || null,
              distance:
                batchedMesh?.lodInfo[unitName].unitDist[instanceId] || null,
            });
            // Remove the private event after triggering
            this._removeTransitionEvent(
              unitName,
              state.animName,
              relativeFrame
            );
          }
        }

        state.localFrame = relativeFrame;

        this._checkStates(unitName, instanceId);

        // Update instance data texture
        instanceDataArr[finalOffset] = state.currentFrame;
        instanceDataArr[finalOffset + 1] = state.nextFrame;
        instanceDataArr[finalOffset + 2] = state.mixFactor;
        instanceDataArr[finalOffset + 3] = state.speed;
      });
    });

    instanceDataTexture.needsUpdate = true;
  }

  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  /* ======================> STATES LOGIC <========================*/
  /* ------------------------------------------------------------- */
  /* ------------------------------------------------------------- */
  setState(unitName, instanceId, stateName) {
    if (
      !unitName ||
      !instanceId ||
      !stateName ||
      !this.materialData?.[unitName]?.instanceStates?.[instanceId]
    )
      return;

    const unitStates = this.materialData[unitName].instanceStates;

    if (!unitStates[instanceId].currentDistanceState) {
      Object.assign(unitStates[instanceId], {
        currentDistanceState: null,
        currentState: "default",
        lastDistanceState: null,
        transitionTriggered: false,
        transitionActive: false,
      });
    }

    unitStates[instanceId].currentState = stateName || "";
  }

  getState(unitName, instanceId) {
    if (
      !unitName ||
      !instanceId ||
      !this.materialData?.[unitName]?.instanceStates?.[instanceId]?.currentState
    )
      return;

    const unitState =
      this.materialData[unitName].instanceStates[instanceId].currentState;

    return unitState;
  }

  setDistanceState(unitName, config, callBack = null) {
    if (!this.materialData?.[unitName] || !unitName || !config?.distName) {
      console.warn("Invalid parameters for setDistanceState");
      return;
    }

    this.materialData[unitName].stateTypes ??= { distance: {} };

    const { distName, min = 0, max = Infinity, states = {} } = config;

    if (Object.keys(states).length === 0) {
      console.warn("No states defined in setDistanceState");
      return;
    }

    const processedStates = {};
    for (const [stateName, stateConfig] of Object.entries(states)) {
      processedStates[stateName] = {
        anim: stateConfig.anim,
        mode: stateConfig.mode || "loop",
        speed: stateConfig.speed || "1",
      };
    }

    this.materialData[unitName].stateTypes.distance[distName] = {
      min,
      max,
      states: processedStates,
      logic: callBack,
    };
  }

  getDistanceState(unitName, instanceId) {
    if (!this.batchedMesh) return console.warn("Set batched mesh first!");
    if (!this.materialData?.[unitName]?.stateTypes || !unitName || !instanceId)
      return;
    if (!this.materialData?.[unitName]?.instanceStates?.[instanceId]) return;

    const { batchedMesh } = this;
    const unitLodInfo = batchedMesh.lodInfo?.[unitName];

    if (!unitLodInfo?.unitDist) return;

    const unitStateTypes = this.materialData[unitName].stateTypes;
    const unitStates = this.materialData[unitName].instanceStates;

    if (!unitStateTypes?.distance) {
      console.warn("Use setDistanceState first!");
      return;
    }

    if (!unitStates[instanceId].currentDistanceState) {
      Object.assign(unitStates[instanceId], {
        currentDistanceState: null,
        currentState: "default",
        lastDistanceState: null,
        transitionTriggered: false,
        transitionActive: false,
      });
    }

    const instanceStates = unitStates[instanceId];

    const currentInstanceDistance = unitLodInfo.unitDist[instanceId];
    const statesDistance = unitStateTypes.distance;

    const [newState] =
      Object.entries(statesDistance).find(
        ([_, data]) =>
          currentInstanceDistance <= data.max &&
          currentInstanceDistance >= data.min
      ) || [];

    if (newState && instanceStates.currentDistanceState !== newState) {
      instanceStates.lastDistanceState =
        instanceStates.currentDistanceState || newState;

      instanceStates.currentDistanceState = newState;
    }

    return {
      instanceStates,
    };
  }

  _checkStates(unitName, instanceId) {
    if (!this.materialData[unitName]) {
      console.warn(`Unit ${unitName} not found`);
      return;
    }

    if (!this.materialData[unitName]?.stateTypes) {
      return;
    }

    const { instanceStates } = this.getDistanceState(unitName, instanceId);

    const { currentDistanceState, currentState } = instanceStates;

    if (instanceStates) {
      const unitStates = this.materialData[unitName].stateTypes;

      const statesDistance = unitStates.distance;

      if (
        !currentState &&
        !statesDistance?.[currentDistanceState]?.states?.default
      ) {
        console.warn("Set default or any other state in setDistanceState");
        return;
      }

      const currentAnim =
        this.materialData[unitName].instanceStates?.[instanceId]?.animName;

      if (
        !currentDistanceState ||
        !statesDistance?.[currentDistanceState]?.states?.[currentState]?.anim
      ) {
        return;
      }

      const currentStateData =
        statesDistance[currentDistanceState].states[currentState];

      // setDistanceState callback is here
      if (statesDistance[currentDistanceState].logic) {
        const { batchedMesh } = this;
        const matrixArray = batchedMesh?.matrices?.[unitName];
        const offset = instanceId * 16;

        _stateEvent.unitName = unitName;
        _stateEvent.instanceId = instanceId;
        _stateEvent.matrix.fromArray(matrixArray, offset);
        _stateEvent.distance =
          batchedMesh.lodInfo[unitName].unitDist[instanceId];
        _stateEvent.distanceState = currentDistanceState || "";
        _stateEvent.stateName = currentState || "";
        _stateEvent.animName = currentAnim || "";
        _stateEvent.speed =
          this.materialData[unitName].instanceStates[instanceId]?.speed || 0;
        _stateEvent.custom =
          this.materialData[unitName].instanceStates[instanceId];

        statesDistance[currentDistanceState].logic(_stateEvent);
      }

      // Transition logic is here
      const finalAnimName = currentStateData.anim;
      const finalAnimSpeed = currentStateData.speed || 0.5;
      const finalAnimMode = currentStateData.mode || "loop";

      if (currentAnim === finalAnimName) return;

      const isCurrentAnimTransit =
        this.materialData[unitName].animations?.[currentAnim]?.isTransit;

      const currentFrameLocal =
        this.materialData[unitName].instanceStates?.[instanceId]?.localFrame;

      const currentAnimLastFrame =
        this.materialData[unitName].animations[currentAnim]?.frameCount - 2;

      const { transitionTriggered, transitionActive } = instanceStates;

      if (!transitionTriggered && !transitionActive) {
        if (
          !isCurrentAnimTransit &&
          currentFrameLocal >= currentAnimLastFrame
        ) {
          instanceStates.transitionTriggered ||= true;
          instanceStates.transitionActive ||= true;

          this.pauseAnimation(unitName, instanceId);

          const currentAnimTransition =
            this.materialData[unitName].animations[currentAnim].transitions[
              finalAnimName
            ];

          if (!currentAnimTransition) return;

          this.playAnimation(
            unitName,
            instanceId,
            currentAnimTransition,
            "once",
            1.5
          );
        }
      } else if (transitionTriggered && transitionActive) {
        if (isCurrentAnimTransit) {
          if (currentFrameLocal >= currentAnimLastFrame) {
            instanceStates.transitionTriggered = false;
            instanceStates.transitionActive = false;

            this.playAnimation(
              unitName,
              instanceId,
              finalAnimName,
              finalAnimMode,
              finalAnimSpeed
            );
          }
        }
      }
    }
  }
}

/**
 * @typedef {Object} GridOptions
 * @property {THREE.Vector3} [start=new THREE.Vector3(0,0,0)]
 * @property {THREE.Vector3} [spacing=new THREE.Vector3(600,0,600)]
 * @property {number} [columns=Math.ceil(Math.sqrt(count))]
 * @property {number} [scale=200]
 * @property {THREE.Euler} [rot=new THREE.Euler(-Math.PI/2,0,0)]
 */

const _playerPos = new THREE.Vector3();
const _center = new THREE.Vector3();
const _mat = new THREE.Matrix4();

class BatchedMeshLod extends THREE.BatchedMesh {
  constructor(units, material) {
    const { maxInstances, maxVertices, maxIndices } = countCapacity(units);

    super(maxInstances, maxVertices, maxIndices, material);

    // This is used to set attributes per all lods of one unit easily. Global buffer (one Float32 for all vertices)
    const unitIndexBuf = new Uint8Array(maxVertices);
    const instanceIndexBuf = new Uint16Array(maxVertices);
    const mapIndexBuf = new Uint8Array(maxVertices);
    const instanceAmountBuf = new Uint16Array(maxVertices);

    let vertexCursor = 0;

    this.units = units;
    this.lodPairs = {};
    this.mapIndex = {};

    Object.entries(units).forEach(
      ([unitName, { geometries, instancesAmount = 0 }], unitIndex) => {
        this.units[unitName].startVertex = vertexCursor;
        this.units[unitName].unitTotalVertices = 0;

        this.lodPairs[unitName] = [];
        this.mapIndex[unitName] = unitIndex; // Initialize mapIndex for each unit

        for (let i = 0; i < instancesAmount; i++) {
          for (const geom of geometries) {
            const cloned = geom.clone(); // IMPORTANT: clone
            const geomId = this.addGeometry(cloned);
            const instId = this.addInstance(geomId);
            this.lodPairs[unitName].push({
              geometryId: geomId,
              instanceId: instId,
            });

            const vc = geom.attributes.position.count;
            this.units[unitName].unitTotalVertices += vc;

            unitIndexBuf.fill(unitIndex, vertexCursor, vertexCursor + vc);

            instanceIndexBuf.fill(i, vertexCursor, vertexCursor + vc);

            mapIndexBuf.fill(unitIndex, vertexCursor, vertexCursor + vc);

            instanceAmountBuf.fill(
              instancesAmount,
              vertexCursor,
              vertexCursor + vc
            );

            vertexCursor += vc;
          }
        }
      }
    );

    this.geometry.setAttribute(
      "unitIndex",
      new THREE.BufferAttribute(unitIndexBuf, 1, false)
    );
    this.geometry.setAttribute(
      "instanceIndex",
      new THREE.BufferAttribute(instanceIndexBuf, 1, false)
    );
    this.geometry.setAttribute(
      "mapIndex",
      new THREE.BufferAttribute(mapIndexBuf, 1, false)
    );
    this.geometry.setAttribute(
      "instancesAmount",
      new THREE.BufferAttribute(instanceAmountBuf, 1, false)
    );

    unitIndexBuf.needsUpdate = true;
    instanceIndexBuf.needsUpdate = true;
    mapIndexBuf.needsUpdate = true;
    instanceAmountBuf.needsUpdate = true;

    /* ------------------------------------------------------------- */
    /* ------------------------------------------------------------- */

    this.lodInfo = {};

    for (const [unitName, { distLod, instancesAmount }] of Object.entries(
      units
    )) {
      const lodCount = distLod.length + 1;
      this.lodInfo[unitName] = {
        lodBase: new Int32Array(instancesAmount), // base index for each instance - the same for all lods
        lodDist: new Float32Array(distLod), // distance thresholds
        lodCount,
        unitDist: {},
      };

      for (let i = 0; i < instancesAmount; i++) {
        this.lodInfo[unitName].lodBase[i] = i * lodCount;
      }
    }

    this.matrices = {};

    for (const [unitName, { instancesAmount }] of Object.entries(units)) {
      this.matrices[unitName] = new Float32Array(instancesAmount * 16);
    }

    this.material = material;
  }

  update(camera) {
    const camPos = _playerPos.setFromMatrixPosition(camera.matrixWorld);

    for (const [
      unitName,
      { lodBase, lodDist, lodCount, unitDist },
    ] of Object.entries(this.lodInfo)) {
      const matrices = this.matrices[unitName];
      const pairs = this.lodPairs[unitName];

      for (let i = 0; i < lodBase.length; i++) {
        // 1. read matrix
        _mat.fromArray(matrices, i * 16);

        // 2. distance check
        const instancePos = _center.setFromMatrixPosition(_mat);

        const dist = camPos.distanceTo(instancePos);

        // 3. pick LoD
        let lod = 0;
        while (lod < lodDist.length && dist > lodDist[lod]) lod++;

        // 4. absolute index in pairs array
        const absIndex = lodBase[i] + lod;

        // 5. set matrix for the chosen LoD instance
        const { instanceId } = pairs[absIndex];
        this.setMatrixAt(instanceId, _mat);

        unitDist[i] = dist; // Update every unit current distance

        // 6. hide the rest
        for (let l = 0; l < lodCount; l++) {
          const other = pairs[lodBase[i] + l].instanceId;
          this.setVisibleAt(other, l === lod);
        }
      }
    }

    this.updateMatrixWorld(true);

    this.computeBoundingBox();
    this.computeBoundingSphere();
  }

  setMatrix(unitName, instanceIndex, matrix4) {
    if (!this.matrices?.[unitName]) {
      console.error(`Unit ${unitName} does not exist.`);
      return null;
    }

    const matrices = this.matrices[unitName];
    const offset = instanceIndex * 16;
    if (offset < 0 || offset + 15 >= matrices.length) {
      console.error(
        `Instance index ${instanceIndex} is out of range for unit ${unitName}.`
      );
      return;
    }

    matrix4.toArray(matrices, offset);
  }

  getMatrix(unitName, instanceIndex) {
    if (!this.matrices?.[unitName]) {
      console.error(`Unit ${unitName} does not exist.`);
      return null;
    }

    const matrices = this.matrices[unitName];
    const offset = instanceIndex * 16;
    if (offset < 0 || offset + 15 >= matrices.length) {
      console.error(
        `Instance index ${instanceIndex} is out of range for unit ${unitName}.`
      );
      return;
    }

    const matrix4 = new THREE.Matrix4();

    return matrix4.fromArray(this.matrices[unitName], instanceIndex * 16);
  }

  setMapIndex(unitName, value) {
    if (!(unitName in this.mapIndex)) {
      console.error(`Unit ${unitName} does not exist.`);
      return;
    }

    const unitStartVertex = this.units[unitName].startVertex;
    const unitTotalVertices = this.units[unitName].unitTotalVertices;
    const unitEndVertex = unitStartVertex + unitTotalVertices;

    const mapIndexAttr = this.geometry.attributes.mapIndex.array;
    mapIndexAttr.fill(value, unitStartVertex, unitEndVertex);

    this.geometry.attributes.mapIndex.needsUpdate = true;
    this.mapIndex[unitName] = value;
  }

  getUnitInstancesAmount(unitName) {
    if (!this.units[unitName]) {
      console.error(`Unit ${unitName} does not exist.`);
      return 0;
    }

    return this.units[unitName].instancesAmount;
  }

  getUnitInstanceDistance(unitName, instanceIndex) {
    if (!this.lodInfo?.[unitName].unitDist?.[instanceIndex]) {
      console.error(`Unit ${unitName} unitDist does not exist.`);
      return;
    }

    return this.lodInfo[unitName].unitDist[instanceIndex];
  }

  unitLookAt(
    unitName,
    instanceIndex = 0,
    targetVector = new THREE.Vector3(0, 0, 0),
    rotationOffset = Math.PI
  ) {
    if (!unitName || !targetVector) {
      console.warn(
        "UnitLookAt error! Set unitName, instanceIndex and targetVector"
      );
      return;
    }

    const matrix = this.getMatrix(unitName, instanceIndex);

    const position = new THREE.Vector3();
    const scale = new THREE.Vector3();
    const quaternion = new THREE.Quaternion();

    matrix.decompose(position, quaternion, scale);

    // Direction to camera/any other Vector3 object, flattened to XZ plane
    const dir = new THREE.Vector3()
      .subVectors(targetVector, position)
      .setY(0)
      .normalize();

    const lookAtMatrix = new THREE.Matrix4().lookAt(
      position,
      position.clone().add(dir),
      new THREE.Vector3(0, 1, 0) // World up
    );

    lookAtMatrix.multiply(new THREE.Matrix4().makeRotationY(rotationOffset));

    const lookQuaternion = new THREE.Quaternion().setFromRotationMatrix(
      lookAtMatrix
    );

    const finalMatrix = new THREE.Matrix4();
    finalMatrix.compose(position, lookQuaternion, scale);

    this.setMatrix(unitName, instanceIndex, finalMatrix);
  }

  unitMoveTowards(
    unitName,
    instanceIndex,
    targetVector,
    lerpFactor,
    stopDistance = 0,
    rotateToTarget = false
  ) {
    if (!unitName || !targetVector) return;

    const dist = this.getUnitInstanceDistance(unitName, instanceIndex);
    if (stopDistance > 0 && dist <= stopDistance) return;

    const matrix = this.getMatrix(unitName, instanceIndex);
    if (!matrix) return;

    const pos = new THREE.Vector3();
    const scale = new THREE.Vector3();
    const quat = new THREE.Quaternion();
    matrix.decompose(pos, quat, scale);

    // Keep original Y
    const currentY = pos.y;
    const xzTarget = new THREE.Vector3(
      targetVector.x,
      currentY,
      targetVector.z
    );

    // Horizontal lerp
    pos.lerp(xzTarget, lerpFactor);

    if (rotateToTarget) {
      const dir = new THREE.Vector3()
        .subVectors(xzTarget, pos)
        .setY(0)
        .normalize();

      const look = new THREE.Matrix4()
        .lookAt(pos, pos.clone().add(dir), new THREE.Vector3(0, 1, 0))
        .multiply(new THREE.Matrix4().makeRotationY(Math.PI));

      quat.setFromRotationMatrix(look);
    }

    const final = new THREE.Matrix4().compose(pos, quat, scale);
    this.setMatrix(unitName, instanceIndex, final);
  }

  /**
   * @param {string} unitName
   * @param {number} count
   * @param {GridOptions} opts
   */
  placeGrid(unitName, count, /** @type {GridOptions} */ opts = {}) {
    const start = opts.start || new THREE.Vector3(0, 0, 0);
    const spacing = opts.spacing || new THREE.Vector3(600, 0, 600);
    const columns = opts.columns || Math.ceil(Math.sqrt(count));
    const scale = opts.scale || 200;
    const rot = opts.rot || new THREE.Euler(0, 0, 0);

    const pos = new THREE.Vector3();
    const scl = new THREE.Vector3(scale, scale, scale);
    const quat = new THREE.Quaternion().setFromEuler(rot);
    const mat = new THREE.Matrix4();

    for (let i = 0; i < count; i++) {
      const col = i % columns;
      const row = Math.floor(i / columns);

      pos
        .copy(start)
        .add(
          new THREE.Vector3(col * spacing.x, row * spacing.y, row * spacing.z)
        );

      mat.compose(pos, quat, scl);
      this.setMatrix(unitName, i, mat);
    }
  }
}

function countCapacity(units) {
  let maxInstances = 0;

  for (const { geometries, instancesAmount = 0 } of Object.values(units)) {
    maxInstances += instancesAmount * geometries.length;
  }

  // sum vertices & indices across all clones
  let maxVertices = 0;
  let maxIndices = 0;
  for (const { geometries, instancesAmount = 0 } of Object.values(units)) {
    for (const geo of geometries) {
      maxVertices += geo.attributes.position.count * instancesAmount;
      maxIndices += (geo.index?.count ?? 0) * instancesAmount;
    }
  }

  return { maxInstances, maxVertices, maxIndices };
}

export { BatchedMeshLod, SkinnedBatchMaterial };