Lightmap not using UV1/UV2 in MeshStandardMaterial (but works perfectly in ShaderMaterial)

Hi everyone,

I am encountering a persistent issue with lightmaps not using UV1 or UV2 when assigned to a MeshStandardMaterial. My geometry contains:

  • correct uv

  • correct uv1

  • I also manually create uv2 from uv1 to match Three.js requirements

However, MeshStandardMaterial’s lightMap is never mapped correctly.
The strange part is that if I use a custom ShaderMaterial and manually sample the lightmap using uv1, the mapping is perfect, which confirms:

  • my UV1 unwrap is correct

  • the lightmap texture is correct

  • there is no mismatch in texel density

  • geometry attributes are correct and aligned

This leads me to believe that Three.js is doing something internally that prevents my UV1/UV2 from being used as expected.

Below are details and code.


import { BackSide, Color, Float32BufferAttribute, Mesh, MeshBasicMaterial, MeshStandardMaterial, ShaderMaterial, SphereGeometry } from "three";
import { GlobalUniforms } from "../../../Utils/Globals/GlobalUniforms";
import { Globals } from "../../../Utils/Globals/Globals";
import { ASSETS } from "../../../config";

export class StoneFloor {
    constructor(scene) {
        this.scene = scene

        this.ready = new Promise(resolve => {
            this.isReady = resolve
        })

        this.init()
    }
    
    async init() {
        const geom = await Globals.loading.modelLoader.load(ASSETS.models.ch1.drc.floor)

        const color = Globals.loading.loadTexture.load(ASSETS.textures.ch1.floor.baseColor, "srgb-repeat");
        const roughness = Globals.loading.loadTexture.load(ASSETS.textures.ch1.floor.roughness, "linear-repeat");
        const height = Globals.loading.loadTexture.load(ASSETS.textures.ch1.floor.height, "linear-repeat");
        const normal = Globals.loading.loadTexture.load(ASSETS.textures.ch1.floor.normal, "linear-repeat");

        // did this in case using uv2 internally, so geometry now have both uv1 and uv2.
        geom.setAttribute('uv2',new Float32BufferAttribute(geom.attributes.uv1.array,2));
        
        
        Globals.textureHelper.inspectTexture(this.scene.lightmap, {
            id: 'lightmap-texture',
            position: 'top-right',
            size: 250,
            borderColor: '#ff0000'
        });

        Globals.textureHelper.debugUVIsland(this.scene.lightmap, geom, 'uv1', {
           id: 'lightmap-uv-island',
            position: 'top-left',
            size: 250,
            borderColor: '#fff200ff',
            uvColor: '#ff8800ff',
            uvLineWidth: 1,
            vertexColor: '#1100ffff',
            vertexSize: 1,
            flipY: false  // Match Blender UV space
        });

        await Promise.all([
            color._loaded,
            roughness._loaded,
            height._loaded,
            normal._loaded
        ]);

        const mat = new MeshStandardMaterial({
            map: color,
            displacementMap: height,
            displacementScale:0.1,
            aoMapIntensity: 1.0,
            roughnessMap: roughness,
            normalMap: normal,
            metalness: 0.0,
            roughness: 1.0,
            lightMap: this.scene.lightmap,
            lightMapIntensity: 1,
        })
        
        this.mesh = new Mesh(geom, mat);
        this.mesh.name = "floor_ch#1";
        this.mesh.position.y = -2.5
        this.mesh.updateMatrixWorld(true);
        this.mesh.matrixAutoUpdate = false;
        this.mesh.receiveShadow = false;
        this.mesh.castShadow = false;
        this.scene.add(this.mesh);
        this.isReady();

        Globals.guiHelper && this.initGUIConfig(Globals.guiHelper, 1);
    }
    
    initGUIConfig(guiHelper, tabIndex) {

        // Mesh config
        const meshConfig = {
            position: { 
                type: "vector3", 
                value: { x: this.mesh.position.x, y: this.mesh.position.y, z: this.mesh.position.z },
                params: { step: 0.1 }
            },
            rotation: { 
                type: "vector3", 
                value: { x: this.mesh.rotation.x, y: this.mesh.rotation.y, z: this.mesh.rotation.z },
                params: { step: 0.01 }
            },
            scale: { 
                type: "vector3", 
                value: { x: this.mesh.scale.x, y: this.mesh.scale.y, z: this.mesh.scale.z },
                params: { min: -10, max: 10, step: 0.1 }
            }
        };

        guiHelper.addUIFolder(meshConfig, "Floor/Mesh", tabIndex, {
            target: this.mesh,
            expanded: false,
            autoSync: true,
            onUpdate: (inputName, value, target) => {
                if (inputName === 'renderOrder') {
                    target.renderOrder = value;
                }
                
                if (inputName === 'position' || inputName === 'rotation' || inputName === 'scale') {
                    target.updateMatrix();
                    target.updateMatrixWorld(true);
                }
            }
        });
    }
}

The result:
lightmap appears completely wrong - misaligned.

it works perfectly in shader material

       const mat = new ShaderMaterial({
            uniforms: {
                lightMap: { value:this.scene.lightmap },
                lightMapIntensity: { value: 2.0 },
                debugMode: { value: 0.0 }  
            },
            vertexShader: `
                varying vec2 vUv;
                varying vec2 vUv1;
                
                void main() {
                    vUv = uv;
                    
                    #ifdef USE_UV1
                        vUv1 = uv1;
                    #else
                        vUv1 = uv;
                    #endif
                    
                    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
                }
            `,
            fragmentShader: `
                uniform sampler2D lightMap;
                uniform float lightMapIntensity;
                uniform float debugMode;
                
                varying vec2 vUv;
                varying vec2 vUv1;
                
                void main() {
                    // Debug mode 1: Visualize UV1 coordinates
                    if (debugMode > 0.5 && debugMode < 1.5) {
                        gl_FragColor = vec4(vUv1.x, vUv1.y, 0.0, 1.0);
                        return;
                    }
                    
                    // Debug mode 2: Show UV bounds + sampled color info
                    if (debugMode > 1.5) {
                        float outOfBounds = step(1.0, vUv1.x) + step(vUv1.x, 0.0) + 
                                           step(1.0, vUv1.y) + step(vUv1.y, 0.0);
                        if (outOfBounds > 0.5) {
                            gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);  // Red for out of bounds
                            return;
                        }
                        
                        // Show the actual sampled color to debug black faces
                        vec3 sampledColor = texture2D(lightMap, vUv1).rgb;
                        float brightness = (sampledColor.r + sampledColor.g + sampledColor.b) / 3.0;
                        
                        // If very dark, show it in blue to identify the issue
                        if (brightness < 0.01) {
                            gl_FragColor = vec4(0.0, 0.0, 1.0, 1.0);  // Blue for black samples
                            return;
                        }
                        
                        gl_FragColor = vec4(sampledColor, 1.0);  // Show actual sampled color
                        return;
                    }
                    
                    // Normal mode: Sample lightmap
                    vec3 lightMapColor = texture2D(lightMap, vUv1).rgb;
                    vec3 baseColor = vec3(1.0);
                    vec3 finalColor = baseColor * lightMapColor * lightMapIntensity;
                    
                    // Add small ambient so it's never completely black (helps debugging)
                    finalColor += vec3(0.001);
                    
                    gl_FragColor = vec4(finalColor, 1.0);
                }
            `,
            defines: {
                USE_UV1: geom.attributes.uv1 !== undefined
            },
            side: DoubleSide
        });

This tells me that:

  • UV1 in the geometry is correct
  • the lightmap texture is correct
  • the baking in Blender is correct
  • the issue exists only inside MeshStandardMaterial’s internal pipeline

Any insight, guidance, or debugging ideas would be greatly appreciated.
I have been stuck on this for days, and since the ShaderMaterial works perfectly, I know the issue is not with my geometry.

Thank you in advance.

Try flipY: true if not help, then make simple plane with lightmap to see how uv rotated. If not help then maybe send model with texture to test.

I tried, but this is not an issue with UV flipping. The problem is that the UVs are not mapping correctly to the texture, so the result appears stretched and chunky.

here are the model and lightmap
they are in drc and ktx2 format and decoding drc file require this function for additional attribute uv1

    let dracoDecoderModule = null;
  
    const initDracoDecoder = async () => {
      if (dracoDecoderModule) return dracoDecoderModule;
      
      return new Promise((resolve) => {
        const script = document.createElement('script');
        script.src = `${THREE_PATH}/examples/jsm/libs/draco/draco_decoder.js`;
        script.onload = () => {
          DracoDecoderModule().then((module) => {
            dracoDecoderModule = module;
            console.log("✓ Draco decoder module loaded");
            resolve(module);
          });
        };
        document.head.appendChild(script);
      });
    };

   const dracoModulePromise = initDracoDecoder();

   async function loadDRCWithAllAttributes(url) {
      const module = await dracoModulePromise;
      const response = await fetch(Globals.env.absolutePath + '/' + url);
      const arrayBuffer = await response.arrayBuffer();
      
      const decoder = new module.Decoder();
      const buffer = new module.DecoderBuffer();
      buffer.Init(new Int8Array(arrayBuffer), arrayBuffer.byteLength);

      const geometryType = decoder.GetEncodedGeometryType(buffer);

      let dracoGeometry;
      let status;

      if (geometryType === module.TRIANGULAR_MESH) {
        dracoGeometry = new module.Mesh();
        status = decoder.DecodeBufferToMesh(buffer, dracoGeometry);
      } else {
        dracoGeometry = new module.PointCloud();
        status = decoder.DecodeBufferToPointCloud(buffer, dracoGeometry);
      }

      if (!status.ok() || dracoGeometry.ptr === 0) {
        throw new Error('Draco decoding failed: ' + status.error_msg());
      }

      const geometry = new BufferGeometry();
      const numPoints = dracoGeometry.num_points();
      const numFaces = dracoGeometry.num_faces();

      const numAttributes = dracoGeometry.num_attributes();
      let uvIndex = 0;

      for (let i = 0; i < numAttributes; i++) {
        const attribute = decoder.GetAttribute(dracoGeometry, i);
        const attributeType = attribute.attribute_type();
        const numComponents = attribute.num_components();
        const numValues = numPoints * numComponents;

        const ptr = module._malloc(numValues * 4);
        decoder.GetAttributeDataArrayForAllPoints(
          dracoGeometry,
          attribute,
          module.DT_FLOAT32,
          numValues * 4,
          ptr
        );

        const array = new Float32Array(module.HEAPF32.buffer, ptr, numValues).slice();
        module._free(ptr);

        let attributeName;

        if (attributeType === module.POSITION) {
          attributeName = 'position';
        } else if (attributeType === module.NORMAL) {
          attributeName = 'normal';
        } else if (attributeType === module.COLOR) {
          attributeName = 'color';
        } else if (attributeType === module.TEX_COORD) {
          attributeName = uvIndex === 0 ? 'uv' : `uv${uvIndex}`;
          uvIndex++;
        } else if (attributeType === module.GENERIC) {
          if (numComponents === 2 && !geometry.attributes.uv1) {
            attributeName = 'uv1';
          } else if (numComponents === 2 && !geometry.attributes.uv2) {
            attributeName = 'uv2';
          } else if (numComponents === 4 && !geometry.attributes.tangent) {
            attributeName = 'tangent';
          } else {
            attributeName = `generic_${i}`;
          }
        } else {
          attributeName = `attr_${i}`;
        }

        geometry.setAttribute(attributeName, new BufferAttribute(array, numComponents));
      }

      if (geometryType === module.TRIANGULAR_MESH && numFaces > 0) {
        const numIndices = numFaces * 3;
        const ptr = module._malloc(numIndices * 4);
        decoder.GetTrianglesUInt32Array(dracoGeometry, numIndices * 4, ptr);
        const indices = new Uint32Array(module.HEAPU32.buffer, ptr, numIndices).slice();
        module._free(ptr);
        geometry.setIndex(new BufferAttribute(indices, 1));
      }

      module.destroy(dracoGeometry);
      module.destroy(buffer);

      return geometry;
   }

floor_with_uv1.drc (1.4 KB)
lightmap.ktx2 (884.8 KB)

Appreciate your help :folded_hands:

texture.channel=1;
texture.flipY=false;

Not sure what I’m doing wrong, but it’s not working on my end. Could you please share the full code so I can preview it and see where I might be making a mistake? Thanks though!


Here channel 0, must be 1. I used png texture instead ktx2, because have error.


I put it in three.js folder “examples”.
55555555555555.html (7.5 KB)

Thank you so much, you’re a lifesaver!

It works perfectly when I use the ‘png’ version, but not with the ‘ktx2’ compressed texture. I do need to compress it though, since the PNG is 20MB, which is huge. Do you have any suggestions?

I found site from google search which convert to png and have 3.3mb. If jpg then less. But maybe png format loss light detail.
lightmap.zip (3.3 MB)

Maybe you can flip ktx2 texture via some program. Then not need for png and for flipY and will be small weight, good light detail.