Additive Scene with remote GLB

Hello,

I’m working on a website mainly focused on mobile use (its working already) but the main scene is too heavy and I need to add more assets.

I tried to load external assets with the Additive Scene methods
https://forum.needle.tools/t/additive-scenes
https://forum.needle.tools/t/load-multiple-scene

For now it’s kinda working, but I have a big issue : the interactions are not working anymore.

  • the external loaded scene is linked with some elements of the initial scene (ex: main camera).

I’ve tried some tests for the external loaded scene structure

  1. Add the full interactive structure (with camera, Timelines, GameObjects with linked components, and the concerned meshs)
    → The assets success to load but the timelines didn’t play because of a PlayableDirector issue, and the content isn’t interactive
  2. Add the mesh only without any other components
    → the asset is loaded and interactive with inside elements but it doesn’t link with the rest of the scene

I’m not sure how to handle this kind of problem, and progressive/lazy loading with external asset would be key for a mobile use.

What would be a way to make it work?

Here is the code I use below

import { Behaviour, serializable, GameObject, AssetReference, InstantiateOptions } from "@needle-tools/engine";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
import { ChunkConfiguration, ChunkConfig } from "./ChunkConfiguration";

export class ModularChunkLoader extends Behaviour {

    @serializable()
    enableDebugLogs: boolean = true;
    @serializable()
    loadOnStart: boolean = false;
    @serializable()
    preloadHighPriorityChunks: boolean = true;
    @serializable(GameObject)
    configurationObject?: GameObject;

    private chunkConfig?: ChunkConfiguration;
    private loadedChunks = new Map<string, GameObject>();
    private loadingPromises = new Map<string, Promise<boolean>>();

    awake(): void {
        if (this.enableDebugLogs) {
            console.log('🚀 ModularChunkLoader: Initialisation (version AssetReference.instantiate)');
        }
    }

    start(): void {
        this.initializeConfiguration();
        if (this.loadOnStart) {
            this.loadAutoLoadChunks();
        }
        
        if (this.preloadHighPriorityChunks) {
            setTimeout(() => {
                this.preloadPriorityChunks();
            }, 2000);
        }

        setTimeout(() => {
            this.preloadDelayedMobileChunks();
        }, 3000);
    }

    private initializeConfiguration(): void {
        if (this.configurationObject) {
            this.chunkConfig = this.configurationObject.getComponent(ChunkConfiguration) || undefined;
        }
        if (!this.chunkConfig) {
            this.chunkConfig = this.context.scene.getComponentInChildren(ChunkConfiguration) || undefined;
        }
        if (!this.chunkConfig) {
            console.error("ModularChunkLoader: Aucune ChunkConfiguration trouvée !");
            return;
        }
        if (this.enableDebugLogs) {
            const availableChunks = this.chunkConfig.getAvailableChunkNames();
            console.log(`ModularChunkLoader: ${availableChunks.length} chunks disponibles:`, availableChunks);
        }
    }

    public async loadAutoLoadChunks(): Promise<void> {
        if (!this.chunkConfig) return;
        const autoLoadChunks = this.chunkConfig.getAutoLoadChunks();
        
        if (autoLoadChunks.length === 0) return;

        if (this.enableDebugLogs) {
            console.log(`ModularChunkLoader: Chargement automatique de ${autoLoadChunks.length} chunks`);
        }

        for (const chunk of autoLoadChunks) {
            try {
                await this.loadChunkByName(chunk.name);
            } catch (error) {
                console.error(`ModularChunkLoader: Erreur chargement auto '${chunk.name}':`, error);
            }
        }
    }

    public async preloadPriorityChunks(): Promise<void> {
        if (!this.chunkConfig) return;

        const priorityChunks = this.chunkConfig.getChunksByPriority(1);

        if (priorityChunks.length === 0) return;

        if (this.enableDebugLogs) {
            console.log(`ModularChunkLoader: Préchargement de ${priorityChunks.length} chunks prioritaires`);
        }

        for (const chunk of priorityChunks) {
            try {
                await this.loadChunkByName(chunk.name);
            } catch (error) {
                console.error(`ModularChunkLoader: Erreur préchargement '${chunk.name}':`, error);
            }
        }
    }

    public async loadChunkByName(chunkName: string): Promise<boolean> {
        if (!this.chunkConfig) {
            console.error("ModularChunkLoader: Configuration non initialisée");
            return false;
        }

        if (this.loadingPromises.has(chunkName)) {
            if (this.enableDebugLogs) {
                console.log(`ModularChunkLoader: '${chunkName}' déjà en cours de chargement`);
            }
            return await this.loadingPromises.get(chunkName)!;
        }
        
        if (this.loadedChunks.has(chunkName)) {
            if (this.enableDebugLogs) {
                console.log(`ModularChunkLoader: '${chunkName}' déjà chargé`);
            }
            return true;
        }

        const config = this.chunkConfig.getChunkConfig(chunkName);
        if (!config) {
            console.error(`❌ ModularChunkLoader: Configuration non trouvĂ©e pour '${chunkName}'`);
            return false;
        }

        const platformChunks = this.chunkConfig.getPlatformCompatibleChunks();
        const isCompatible = platformChunks.some(c => c.name === chunkName);
        
        if (!isCompatible) {
            if (this.enableDebugLogs) {
                console.log(`đŸ“± ModularChunkLoader: Chunk '${chunkName}' non compatible avec cette plateforme`);
            }
            return false;
        }

        const delay = this.chunkConfig.getLoadDelay(chunkName);
        if (delay > 0) {
            if (this.enableDebugLogs) {
                console.log(`đŸ“± ModularChunkLoader: DĂ©lai mobile de ${delay}ms pour '${chunkName}'`);
            }
            await new Promise(resolve => setTimeout(resolve, delay));
        }

        const loadPromise = this.loadChunk(config);
        this.loadingPromises.set(chunkName, loadPromise);

        try {
            const success = await loadPromise;
            this.loadingPromises.delete(chunkName);
            return success;
        } catch (error) {
            this.loadingPromises.delete(chunkName);
            throw error;
        }
    }

    private async loadChunk(config: ChunkConfig): Promise<boolean> {
        const startTime = performance.now();

        try {
            if (config.remoteUrl) {
                if (this.enableDebugLogs) {
                    console.log(`ModularChunkLoader: Chargement distant de '${config.name}' depuis '${config.remoteUrl}'`);
                }

                const success = await this.loadSceneFromUrl(config.remoteUrl, config.name);
                if (success) {
                    const loadTime = performance.now() - startTime;
                    console.log(`✅ ModularChunkLoader: Chunk distant '${config.name}' chargĂ© en ${loadTime.toFixed(2)}ms`);
                    return true;
                }
            }

            if (config.localFallbackPath) {
                if (this.enableDebugLogs) {
                    console.log(`ModularChunkLoader: Fallback local pour '${config.name}' depuis '${config.localFallbackPath}'`);
                }

                const success = await this.loadSceneFromUrl(config.localFallbackPath, config.name);
                if (success) {
                    const loadTime = performance.now() - startTime;
                    console.log(`✅ ModularChunkLoader: Chunk local '${config.name}' chargĂ© en ${loadTime.toFixed(2)}ms`);
                    return true;
                }
            }

            console.error(`❌ ModularChunkLoader: Échec chargement '${config.name}' (distant et local)`);
            return false;

        } catch (error) {
            console.error(`❌ ModularChunkLoader: Erreur chargement '${config.name}':`, error);
            return false;
        }
    }

    private async loadSceneFromUrl(url: string, chunkName: string): Promise<boolean> {
        try {
            if (this.enableDebugLogs) {
                console.log(`ModularChunkLoader: Utilisation du systĂšme Needle Engine pour ${url}`);
            }

            const assetRef = new AssetReference(url);

            const scene = await assetRef.loadAssetAsync();

            if (scene) {
                if (this.enableDebugLogs) {
                    console.log(`ModularChunkLoader: Chargement Needle Engine réussi pour ${url}`);
                }

                this.gameObject.add(scene);

                if (this.enableDebugLogs) {
                    console.log(`🎯 ModularChunkLoader: Chunk '${chunkName}' ajoutĂ© Ă  la scĂšne principale`);
                }

                this.loadedChunks.set(chunkName, scene);

                await this.postProcessChunk(scene, chunkName);

                return true;
            }

            return false;
        } catch (error) {
            if (this.enableDebugLogs) {
                console.log(`ModularChunkLoader: Erreur AssetReference.loadAssetAsync() pour ${url}:`, error);
            }
            return false;
        }
    }

    private async postProcessChunk(scene: GameObject, chunkName: string): Promise<void> {
        try {
            if (this.enableDebugLogs) {
                console.log(`ModularChunkLoader: Post-traitement du chunk '${chunkName}'`);
            }

            if (scene.getComponentsInChildren) {
                const allComponents = scene.getComponentsInChildren(Behaviour);
                if (this.enableDebugLogs) {
                    console.log(`ModularChunkLoader: ${allComponents.length} composants Needle trouvés dans '${chunkName}'`);

                    if (allComponents.length > 0) {
                        const componentTypes = [...new Set(allComponents.map(c => c.constructor.name))];
                        console.log(`ModularChunkLoader: Types de composants: ${componentTypes.join(', ')}`);
                    }
                }

                for (const component of allComponents) {
                    const componentName = component.constructor.name;

                    const problematicComponents = [
                        'SpatialAudioUI',
                        'Fog',
                        'RemoteSkybox',
                        'SceneLightSettings',
                        'WebXR',
                        'NeedleMenu',
                        'Canvas'
                    ];

                    if (problematicComponents.includes(componentName)) {
                        component.enabled = false;
                        if (this.enableDebugLogs) {
                            console.log(`ModularChunkLoader: Désactivé ${componentName} dans '${chunkName}'`);
                        }
                    }
                }
            }

            if (this.enableDebugLogs) {
                console.log(`✅ ModularChunkLoader: Post-traitement terminĂ© pour '${chunkName}'`);
            }

        } catch (error) {
            if (this.enableDebugLogs) {
                console.error(`❌ ModularChunkLoader: Erreur post-traitement '${chunkName}':`, error);
            }
        }
    }

    public async preloadDelayedMobileChunks(): Promise<void> {
        if (!this.chunkConfig) return;

        const delayedChunks = this.chunkConfig.getDelayedMobileChunks();

        if (delayedChunks.length === 0) return;

        if (this.enableDebugLogs) {
            console.log(`đŸ“± ModularChunkLoader: PrĂ©chargement diffĂ©rĂ© de ${delayedChunks.length} chunks mobiles`);
        }
        for (const chunk of delayedChunks) {
            try {
                await this.loadChunkByName(chunk.name);
            } catch (error) {
                console.error(`❌ ModularChunkLoader: Erreur prĂ©chargement diffĂ©rĂ© '${chunk.name}':`, error);
            }
        }
    }

    public async loadChunkOnDemand(chunkName: string): Promise<boolean> {
        if (!this.chunkConfig) {
            console.error("ModularChunkLoader: Configuration non initialisée");
            return false;
        }
        const config = this.chunkConfig.getChunkConfig(chunkName);
        if (!config) {
            console.error(`❌ ModularChunkLoader: Configuration non trouvĂ©e pour '${chunkName}'`);
            return false;
        }
        const platformChunks = this.chunkConfig.getPlatformCompatibleChunks();
        const isCompatible = platformChunks.some(c => c.name === chunkName);
        if (!isCompatible) {
            if (this.enableDebugLogs) {
                console.log(`đŸ“± ModularChunkLoader: Chunk '${chunkName}' non compatible avec cette plateforme`);
            }
            return false;
        }

        return await this.loadChunkByName(chunkName);
    }

    public isChunkLoaded(chunkName: string): boolean {
        return this.loadedChunks.has(chunkName);
    }

    public getLoadedChunk(chunkName: string): GameObject | undefined {
        return this.loadedChunks.get(chunkName);
    }
    
    public unloadChunk(chunkName: string): boolean {
        if (!this.loadedChunks.has(chunkName)) {
            return false;
        }
        const scene = this.loadedChunks.get(chunkName);
        if (scene && scene.parent) {
            scene.parent.remove(scene);
        }
        this.loadedChunks.delete(chunkName);
        if (this.enableDebugLogs) {
            console.log(`đŸ—‘ïž ModularChunkLoader: Chunk '${chunkName}' dĂ©chargĂ©`);
        }
        return true;
    }

    public getLoadedChunkNames(): string[] {
        return Array.from(this.loadedChunks.keys());
    }

    public unloadAllChunks(): void {
        const chunkNames = this.getLoadedChunkNames();
        for (const chunkName of chunkNames) {
            this.unloadChunk(chunkName);
        }
        if (this.enableDebugLogs) {
            console.log(`đŸ—‘ïž ModularChunkLoader: Tous les chunks dĂ©chargĂ©s (${chunkNames.length})`);
        }
    }
    
    onDestroy(): void {
        this.unloadAllChunks();
        this.loadingPromises.clear();
        if (this.enableDebugLogs) {
            console.log("đŸ—‘ïž ModularChunkLoader: Composant dĂ©truit, nettoyage terminĂ©");
        }
    }
}

Thank you for your help

Hi Corentin, thanks for your question. This issue usually arises because the additive (remote) scene you load carries its own interactive elements (like cameras, timelines, or even PlayableDirectors) that conflict with the main scene’s setup. When you load the full interactive structure, the remote scene’s components may refer to objects that aren’t available in the main context (for example, a duplicate camera or timeline bindings), which can cause your timelines to not play correctly and interactions to fail.

A common approach is to separate visual assets from interactive logic. One recommendation is to export only the mesh and visual components from the remote GLB and then, in your main scene, handle interactivity (including colliders and timeline triggers) via dedicated scripts. That way you can keep your main camera and input system intact. If you do need timeline behaviour from the remote asset, you can load the full scene and then reassign its references – for example, after loading you can traverse the loaded hierarchy to disable or remove its camera component and update the PlayableDirector’s bindings so that it points to your main scene’s camera or other scene-specific objects.

Here’s a basic example illustrating how you might process the loaded scene:

import { Behaviour, serializable, GameObject, AssetReference } from "@needle-tools/engine";

export class ExternalSceneLoader extends Behaviour {
  @serializable(AssetReference)
  remoteSceneAsset?: AssetReference;

  async awake() {
    if (!this.remoteSceneAsset) return;
    // Load the remote scene asset (GLB)
    const loadedScene = await this.remoteSceneAsset.loadAssetAsync();

    // Remove or disable duplicate components like cameras (modify this logic as needed)
    loadedScene.traverse(child => {
      // Assuming extra cameras are tagged (or check for child instanceof Camera if available)
      if (child.userData.isCamera) {
         child.visible = false;
      }
    });

    // Reparent the loaded scene to your main scene (or desired parent)
    this.gameObject.add(loadedScene);

    // Optionally, locate the PlayableDirector and reassign its references:
    // e.g. update its binding to refer to the main camera from the main scene.
    // This might look like:
    // const director = loadedScene.getComponent(PlayableDirector);
    // if (director) {
    //   director.mainCamera = this.context.mainCamera;
    //   // update other interactive bindings as needed
    // }
  }
}

By loading only the necessary visual components and handling interactive behaviour centrally in your main scene, you avoid conflicts between duplicate cameras or timeline setups. If you need to keep some interactive components inside the remote scene, make sure you update their references at runtime so they correctly use the main scene’s objects.

This approach is in line with the community discussions on additive scenes at CHANGELOG.md and at react-three-fiber-sample—NeedleEngine.tsx.md. If further issues persist, a minimal reproducible example (e.g., via Needle-engine - Vite Template - StackBlitz) can help diagnose specific reference or timeline problems.

Hope this helps clarify the approach, and feel free to provide additional details if needed!

1 replies left - Become PRO for more replies.
thread_NJ5NHZOmEYBBylD6efcOzVdq
Reply to this post directly if you need further help (bottom right button)

Hello,

I’ve started a new way to import chunks with a sytem of ClickableObject for the chunk to ask what it needs and be able to stay interactive, and a chunkLoader + SceneSevices in my main scene.

Till now it was working well and very promising.

But now that i’m at the part to link the PlayableDirector and timelines of the chunk, I’ve got a bug with the AnimatorController :

  • Everything works fine in the scene during the setup
  • When I export it with NeedleExporter, the AnimatorController go corrupted

    (white file icon)

I had this bug with Needle 4.9.3 and 4.10.2

Is anyone already had this bug and a way to fix/avoid it?

Cheers :victory_hand:

Hi Corentin!

It sounds like you’re running into an issue with the AnimatorController becoming corrupted when exporting with NeedleExporter. This can happen for a variety of reasons, but here are a few troubleshooting steps you can take:

  1. Check Animator Controller Settings: Make sure that all parameters, states, and transitions in your AnimatorController are correctly set up before exporting. Sometimes missing references can lead to corruption during export.

  2. Recreate the Animator: As a workaround, try recreating the AnimatorController from scratch. Sometimes, the original file might have been generated in a way that’s incompatible with the exporter.

  3. Export Settings: Review your export settings in the NeedleExporter to ensure that they aren’t inadvertently excluding any necessary assets or settings.

  4. Update to Latest Version: If you haven’t already, consider updating to the latest version of Needle Engine, as bugs are often fixed in newer releases.

  5. Export a Simple Animator: To isolate the problem, try exporting a simple AnimatorController that has minimal functionality and see if that works. If it does, you can gradually add complexity until you find what’s causing the corruption.

  6. Report a Bug: If the issue persists, I recommend reporting it to the Needle team with details from your setup. Include specific steps to reproduce the problem and any relevant logs.

  7. Community Forums: Check the Needle forums and community discussions. Others may have faced similar issues and could have shared solutions or workarounds.

Feel free to reach out if you have any other questions or need further assistance!

1 replies left - Become PRO for more replies.
thread_NJ5NHZOmEYBBylD6efcOzVdq
Reply to this post directly if you need further help (bottom right button)

Hello Corentin,

I’ve never seen this bug, did you report it previously? Which Unity version are you using? What Needle Engine does on export is only reading the AnimatorController and exporting the data, Needle Engine doesnt write anything back to your assets so it sounds like it could be a Unity issue.

Could you send a minimal bugreport with the scene and instructions on how to reproduce it? It would be also great if you could send the file with the white icon after export.

Hello Marcel,

I figured the exact time it is corrupted (spoiler: it’s not during the Needle Export)

  • When I export my chunk scene, i get a GLB of my scene
  • When I had this chunk.glb to my Unity Assets folder, it goes corrupted

Next test: I will try to add this file in a different folder to see if it’s still affecting my Animation Controller

The white icon file (IAG_Root_Animator.controller) can’t be added here in this message.
Tell me how i can send it to you :slight_smile:

I’m using Unity 2022.3.60f1 and it’s the same whith 2022.3.62f2

Interesting. For my understanding: Why are you adding the GLB that you export from Unity then back to Unity? Insead of referencing the Prefab or Scene Asset in your component for example?

Could you send a bug report using the menu item “Needle Engine/Report a bug” first? Thanks a lot!

Thanks Marcel, I’m trying to get as much as best practices to develop new features.
(I was more a content creator than a developper, so there’s still a lot to learn and I’m working on it!)

It was to help me debugging the behaviour of the chunk loading to compare how its loaded from ChunkLoader.ts and from my initial scene

New clue:

  • When I had the scene Chunk.glb to my folder, it automatically creates a new AnimatorController file (that explain why it was corrupted after the import)

I’ll send you my two scenes with the bug report

  • the initial scene which try to load the chunk
  • the Chunk setting scene
    (But I don’t think the white icon file would be in the file sent ^^’)

This is still not clear to me - when you import your GLB back into Unity and use it in ChunkLoader it does become a Unity Asset again (on import) and is then re-exported as a GLB. This is effectively just adding another cycle to the process.

I’m sorry I haven’t had a chance yet to look at your original question.
Did you send a bugreport with your original project setup?
Two more questions: Have you had a look at the Multiscenes Sample and did you try using the core SceneSwitcher component?

When I look through the code above it seems like you’re reconstructing asset urls in the ChunkConfiguration and then creating AssetReference types again - is there a reason you’re not using AssetReference types directly in your ChunkConfiguration? This would allow you to simply assign the Assets in Unity - no need for any workarounds or guesswork with the URLs

e.g. assuming the Configuration looks something like this:

export class MyChunkConfiguration {

  @serializable(AssetReference)
  asset: AssetReference | null = null;
   
  // more config....
}

In C# for Unity that would become something like this (assuming MyChunkConfiguration isn’t a MonoBehaviour):

[System.Serializable]
public class MyChunkConfiguration {

  public Transform asset;

}
1 Like

I’ve looked at the MultiScenes sample and SceneSwitcher, but I Haven’t success to see how to add chunks with it with remote GLBs

I did, you should have received two BugReport from me

You’re totally right and I’ll avoid it from now on!

I’ll try and test what you suggest and see how it can help me

Thanks for all your advices :grin:

What PlayableDirector issue you mean by this in your original post?

→ The assets success to load but the timelines didn’t play because of a PlayableDirector issue, and the content isn’t interactive

In my first post, I didn’t have any script on the chunk side.
So I think there wasn’t any way to link the playable director wit the main scene.

That’s what I’m working on in the two scenes I sent you with the bug report

You can query the timeline inside the chunk scene from your main scene too but I think you’re already aware of that (loadedScene.getComponentsInChildren(PlayableDirector))

1 Like

Hi @Corentin_Duboc did you get your project to work the way you wanted?

Hey Marcel,
I’m close to make it. I’ve succeeded to load my chunks from a directory in local and keep the interactions working.

I’m still doing the last 2 parts

  • The event communication system with a very simple Event Bus
  • The final test and optimization with a remote GLB from an url
    (last sprint on this feature)

I think it can be a very valuable feature of lazy loading for your sample example.
Tell me if you think it would be the case (or if it exists and I didn’t find it ^^’)

Cheers,
Corentin

Hey everyone,

Here is a summary of the key actions taken
(written with a help of IA to be as clear and helpful as possible :wink:)

  • 1. Unity Chunk Preparation:

    • Clean Chunks: Chunks exported as GLBs must be clean of any scene-unique objects (no Main Camera, EventSystem, global lights, etc.).
  • 2. Main Scene → Chunk Communication (Service Locator Pattern):

    • Created a global singleton script (SceneServices.ts) in the main scene. This acts as a central “address book” for important references like the mainCamera.

    • Scripts inside the loaded chunks now actively fetch their own dependencies from this service (e.g., this.mainCamera = SceneServices.instance.mainCamera;).

  • 3. Chunk → Main Scene Communication (Event Bus Pattern):

    • Created a simple static script (CustomEvents.ts) with addEventListener and dispatchEvent methods to act as a global event bus.

    • When an important event happens inside a chunk (e.g., a timeline sequence ends), its script dispatches a global event: CustomEvents.dispatchEvent("sequence_finished");.

    • A manager script in the main scene listens for these events (CustomEvents.addEventListener("sequence_finished", ...)) and reacts accordingly.

This architecture keeps the chunks fully decoupled and reusable. It solved all my issues with broken references and interactivity.

Hope this helps others facing similar challenges!

Cheers,
Corentin

Hello @Corentin_Duboc

Thanks for sharing! I hope you don’t mind if I share some suggestions - let me know if this is just not known or for some reason didn’t work as a solution:

Regarding 2: You could access the needle-engine context or specific important components like the camera with this.context.mainCamera (or Context.Current to access from anywhere). I guess the camera is a bad example and maybe you use the SceneService to store all kinds of component references?

Regarding 3: Another option would be to simply dispatch the event on the window object (e.g. window.dispatchEvent(new CustomEvent("my-event")) and listen to it window.addEventListener("my-event", evt => { console.log(evt) })

1 Like

Hello @Marcel_Wiessler1

Yes, I had this fallback on my chunk scene to find the camera in case it doesn’t find it in SceneServices
if (!this.mainCamera) {
this.mainCamera = SceneServicesCk.instance?.getMainCamera() ?? GameObject.getComponent(this.context.mainCamera, Camera);
if (this.mainCamera) {
console.log(this.gameObject.name + " found the 'mainCamera'.");
}
}

I’ll had other components in SceneService when I’ll build my website again from this modular chunk foundation.

Thank you for this advice, I didn’t have it in mind!
:smiley:

This topic was automatically closed 13 days after the last reply. New replies are no longer allowed.