Inconsistent positioning of meshes/textures (breaks raycasting behavior)

I have this scene as the landing page of a website. The idea is that a user can click on the name of a continent and then some Javascript runs that creates a popup related to that continent. When it all loads properly (I’d say 50 percent of the time, but this may be more or less), then everything is great, the raycaster intersects with the proper continent label on clicking, and the JS runs as expected. However, the other times, it seems that the textures are loaded offset from the object meshes. I’ve discovered that when this happens, I must click up and to the left of the actual label in order to achieve the expected result… sometimes so far up and left that the click is entirely off of the texture. I am at a total loss of why this is occurring. I’ve console logged the renderers bounding rect, and it seems that it is inconsistent, perhaps this is why? If so, why would it be so inconsistent when using the same device (I’ve seen this issue occur more often on mobile, I’m using a Samsung S24+)? My init() function and internal init functions are pasted below, and I’ll post some screenshots of the inconsistent behavior as an example (The first being what is expected, and the second being the offset loading). The landing page can be found at, perhaps you can recreate the issue yourself… the weird thing is that it is inconsistent and not certainly always reproducible.

Init function:

async function init() {
    //set content

    camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
    camera.position.set(1.5, 1, -2.5);

    prevTime =;
    earth = new THREE.Object3D();
    ufo = new THREE.Object3D();
    flag = new THREE.Object3D();
    pizzaPlane = new THREE.Object3D();
    northAmericaLabel = new THREE.Object3D();
    southAmericaLabel = new THREE.Object3D();
    europeLabel = new THREE.Object3D();
    africaLabel = new THREE.Object3D();
    asiaLabel = new THREE.Object3D();
    australiaLabel = new THREE.Object3D();
    antarcticaLabel = new THREE.Object3D();
    stars = new THREE.Object3D();
    scene = new THREE.Scene();
    loader = new GLTFLoader();
	renderer = new THREE.WebGLRenderer({ alpha: true });
	renderer.setSize( window.innerWidth, window.innerHeight - document.getElementsByTagName("nav-bar")[0].offsetHeight);
    document.getElementById("map").appendChild( renderer.domElement );
    controls = new OrbitControls( camera, renderer.domElement );
    origin = new THREE.Vector3(0,0,0);
    rotationAxis = new THREE.Vector3(0,1,0);

    //click listener
    renderer.domElement.addEventListener("mousedown", clickStart);
    renderer.domElement.addEventListener("mouseup", clickEnd);
    earthClick = false;

    light = new THREE.AmbientLight( 0x404040, 50 ); // soft white light
	scene.add( light );

    //load System
    await loadSystem();

    texts = [northAmericaLabel, southAmericaLabel, europeLabel, africaLabel, asiaLabel, australiaLabel, antarcticaLabel];

    northAmericaLabel.scene.position.setFromSphericalCoords(1.1, -Math.PI * 1/4, -Math.PI * 1/3);
    southAmericaLabel.scene.position.setFromSphericalCoords(1.1, -Math.PI * 7/13, -Math.PI * 1/8);
    europeLabel.scene.position.setFromSphericalCoords(1.1, -Math.PI * 1/4, Math.PI * 3/10);
    africaLabel.scene.position.setFromSphericalCoords(1.1, -Math.PI * 3/7, Math.PI * 1/3);
    asiaLabel.scene.position.setFromSphericalCoords(1.1, -Math.PI * 1/4, Math.PI * 2/3);
    australiaLabel.scene.position.setFromSphericalCoords(1.1, Math.PI * 5/8, 0);
    antarcticaLabel.scene.position.setFromSphericalCoords(1.1, Math.PI, 0);

    bounds = renderer.domElement.getBoundingClientRect();
    pointer = new THREE.Vector2();
    clickStartV = new THREE.Vector2();
    clickEndV = new THREE.Vector2();
    raycaster = new THREE.Raycaster();

    //event listeners
    document.getElementById("resetPivot").addEventListener("click", resetCamera);

    //stop loading animation
    document.getElementById("loading").style.display = "none";


Load System Function:

async function loadSystem() {
	//load objects asynchronously
	let [...system] = await Promise.all([
	earth = system[0];
	ufo = system[1];
	flag = system[2];
	pizzaPlane = system[3];
	northAmericaLabel = system[4];
	southAmericaLabel = system[5];
	europeLabel = system[6];
	africaLabel = system[7];
	asiaLabel = system[8];
	australiaLabel = system[9];
	antarcticaLabel = system[10];
	stars = system[11];

	//positioning and adding
	earth.scene.rotation.y = Math.PI * 3/4;

	ufo.scene.position.set(1.5, 1.5, 1.5);

    flag.scene.position.y = 0.98;
    flag.scene.rotation.y = Math.PI/2;

    pizzaPlane.scene.rotation.z = -Math.PI/2;
    pizzaPlane.scene.rotation.y = Math.PI/4;
    pizzaPlane.scene.position.x = -400;
    pizzaPlane.scene.position.z = 400;



Load Object Function:

function loadObject(id) {
	let result = loader.loadAsync(`./static/objects/${id}.glb`);
	return result;

You didn’t show your raycasting code… I also don’t see any resizing logic for the canvas. This needs to be in there in case some dom elements load and suddenly change/shift the size of the canvas, which requires resizing the renderer.

Generally though, this is probably some kind of logic issue in your code.
Textures don’t really have an “offset” relative to geometry.
If hits aren’t registering correctly it’s usually a sign that the raycaster isn’t being set up right, or that your conversion of mouse to device coordinates is broken/wrong.

1 Like

Thank you for replying! Here are those things… let me know if anything seems illogical to you.

I should note that when the page loads, if the issue happens then everything seems to be sort of closer to the camera… as you can see in the second screenshot. Whereas when everything is working correctly, it appears more like the first screenshot where things are better in view. What’s weird is the location of my objects seem to be in the right place since I can click where they “should” be, and then the popup happens. I hope that makes sense.

Just for better understanding, clickStart is called on a mouseDown event, and clickEnd is called on a mouseUp event. The difference in the locations of these events distinguish between a regular click and a click-and-drag (note the <= 7 check).

//Window Resizing
window.addEventListener('resize', onWindowResize, false)
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight
    renderer.setSize(window.innerWidth, window.innerHeight)
    renderer.render(scene, camera);
    bounds = renderer.domElement.getBoundingClientRect();
function clickStart(event) {
    clickStartV.x = event.clientX - bounds.left;
    clickStartV.y = event.clientY -;

function clickEnd(event) {
    clickEndV.x = event.clientX - bounds.left;
    clickEndV.y = event.clientY -;

    if (clickStartV.distanceTo(clickEndV) <= 7) {
        pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
        pointer.y = -((event.clientY - bounds.y + document.documentElement.scrollTop) / window.innerHeight) * 2 + 1;

        raycaster.setFromCamera(pointer, camera);
        let intersections = raycaster.intersectObjects(scene.children);

        if (intersections.length > 0) {
            let popup = document.getElementById("popup");
            if (intersections[0]"UFO")) {
                location.href = `${window.origin}/alienrecipe`;
            if (intersections[0]"Earth")) {
                if (earthClick == true) {
                    earthClick = false;
                    location.href = `${window.origin}/continent/all`;
                } else {
                    earthClick = true;
            } else {
                earthClick = false;
            if (intersections[0]"Pizza")) {
                location.href = `${window.origin}/feed`;
            if (intersections[0]"Flag")) {
       = "block";
                popup.setAttribute("title", "Planet Pizza");
                popup.setAttribute("url", "/login");
                popup.setAttribute("content", flagContent);
            if (intersections[0]"NorthAmerica")) {
       = "block";
                popup.setAttribute("title", "North America");
                popup.setAttribute("url", "/continent/northamerica");
                popup.setAttribute("content", northAmericaContent);
            if (intersections[0]"SouthAmerica")) {
       = "block";
                popup.setAttribute("title", "South America");
                popup.setAttribute("url", "/continent/southamerica");
                popup.setAttribute("content", southAmericaContent);
            if (intersections[0]"Europe")) {
       = "block";
                popup.setAttribute("title", "Europe");
                popup.setAttribute("url", "/continent/europe");
                popup.setAttribute("content", europeContent);
            if (intersections[0]"Africa")) {
       = "block";
                popup.setAttribute("title", "Africa");
                popup.setAttribute("url", "/continent/africa");
                popup.setAttribute("content", africaContent);
            if (intersections[0]"Asia")) {
       = "block";
                popup.setAttribute("title", "Asia");
                popup.setAttribute("url", "/continent/asia");
                popup.setAttribute("content", asiaContent);
            if (intersections[0]"Australia")) {
       = "block";
                popup.setAttribute("title", "Australia");
                popup.setAttribute("url", "/continent/australia");
                popup.setAttribute("content", australiaContent);
            if (intersections[0]"Antarctica")) {
       = "block";
                popup.setAttribute("title", "Antarctica");
                popup.setAttribute("url", "/continent/antarctica");
                popup.setAttribute("content", antarcticaContent);
        } else {
            earthClick = false;
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight
    renderer.setSize(window.innerWidth, window.innerHeight)
    renderer.render(scene, camera);  <---- this isn't needed if you are already rendering in a RAF

    bounds = renderer.domElement.getBoundingClientRect(); //This.. MAY not be the right bounds that you need.. you might want to verify that the bounds width and height are the same was what you're passing into the renderer.resize. they need to match.


 pointer.y = -((event.clientY - bounds.y + document.documentElement.scrollTop) / window.innerHeight) * 2 + 1;

That looks sus ^. If you’re scaling your renderer to window size, why would it have a scrollTop etc. There may be some nonsense creeping in here

1 Like

I made those edits in the resize function (except for the bounds variable… I will have to rework that) and I also got rid of the scrollTop thing… I think that was a remnant from early development when the page was scroll-able and the render view was just a part of the page. Thank you for pointing these things out.

However, I think the problem may be grander than the bounds thing. The reason is because, all things constant, the scene may load with the appearance of being zoomed in (like the 2nd screenshot) one time, and then load with no zoom the next time (in which case the object textures seem to be clickable like normal). Zoom may not be the right word, because I definitely never zoom or anything like that, I’m just describing the behavior. Sometimes it is way more extreme of a difference than in the screenshots, too.

I tried waiting to call the init() function until the DOMContentLoaded event fires, still no luck.

I should mention that when I use a PC, this issue hardly, if ever, occurs. It’s mainly mobile.

Loading doesn’t look like it should be affecting anything since you are await/all on all the loads… hmmm

I solved it. It was primarily a CSS issue. I had “overflow: hidden” set on my html and body tags which apparently caused the strange behavior. Removing this fixed the raycast intersect issue, but recreated the problem that existed before I implemented the “overflow: hidden” a long time ago… which was that on mobile the entire page would be able to scroll a bit both horizontally and vertically. To fix this, I had to set the height and width properties of the canvas element generated by Three JS to 100vh and 100vw, respectively. A few more things are a bit broken CSS wise now due to removing the overflow setting, but they should be easy to fix with alternative CSS.

Edit: It’s still very strange to me that the issue was not always reproducible… such is web dev I guess.

1 Like