Hey there, I have the following issues with timeline playback:
when I export a scene as a .glb, my timelines don’t play at all. Are there any limitations I should consider?
My scenario: I have a BootScene that loads the MainContent scene (exported as .glb). The MainContent scene contains a Timeline that works when I test the scene directly, but the timeline doesn’t play after I load it from BootScene.
I’m also wondering if I can access animation track bindings via code and reassign them dynamically.
Hi Ilja, thanks for your question and for sharing your scenario. There are a couple of factors to consider when working with exported GLB timelines:
Timelines in a GLB exported from Unity can behave differently when loaded as a subscene (via your BootScene) than when played directly. In some cases, the timeline’s initialization or auto-play settings might not trigger properly when the GLB is loaded asynchronously. This is a known limitation in some versions of the exporter, where—even though the timeline works as a standalone scene—the binding of timeline tracks to their target objects isn’t fully reinitialized after load. In our changelogs there have been fixes (for example, one noted that the first timeline keyframe was exported incorrectly in some cases at UnityGLTF-changelog.md) which indicates that timeline export and playback are still being refined.
About dynamically accessing animation track bindings via code: while the exported GLB bundles the animation data, there isn’t a dedicated, widely documented API to reassign timeline track target bindings at runtime. That said, you can access the timeline component (if it’s exposed in your scene) and write custom code to trigger playback or manually adjust binding references. For example, once your GLB scene is fully loaded you might obtain a reference to your timeline component and then—if the internal API permits—iterate over its track list to re-bind target objects. In practice this means that if you need dynamic re-binding, you must ensure that the timeline component is part of the exported setup and then write a custom Needle Engine component that runs after load, like:
import { Behaviour, serializable } from "@needle-tools/engine";
// (Assuming there’s a TimelineComponent provided by your setup)
import { TimelineComponent } from "@needle-tools/engine";
export class TimelineInitializer extends Behaviour {
@serializable(TimelineComponent)
timeline?: TimelineComponent;
onEnable() {
if (this.timeline) {
// Ensure any custom binding adjustments are applied before playback.
// For instance, if your timeline has a method to rebind tracks, call it here.
this.timeline.play(); // Trigger playback manually if needed.
}
}
}
I recommend checking that you’re using the latest version of the exporter/engine, as improvements and fixes are added regularly. If issues persist, you might try setting up your scene so that after the GLB loads the timeline is explicitly enabled and that its targets are correctly assigned.
If you have a minimal reproducible example (a small project via Needle-engine - Vite Template (duplicated) - StackBlitz), that would help in diagnosing the issue further. In the meantime, consider manually triggering the timeline playback after the GLB content is integrated. Let me know if you need further guidance or have more details to share!
1 replies left - Become PRO for more replies. thread_A9CMmldd6kUgNQbhqUsAVfWW Reply to this post directly if you need further help (bottom right button)
Hey Marcel! Thanks for reaching out.
I’m running BootScene, exported from Unity, at https://192.168.178.88:3000/
In BootScene, I have my manager as a GameObject with the following loader component (this is how I add MainScene.glb):
// src/scripts/MainSceneLoader.ts
import {
Behaviour,
serializable,
AssetReference,
GameObject,
PlayableDirector,
} from "@needle-tools/engine";
// Pull in behaviours that appear in Main so the bundler includes & registers them
import "./ToonShadingShadow";
import "./ToonShading";
import "./ToonShadingEyes";
import "./ToonShadingLightCone";
export class MainSceneLoader extends Behaviour {
// --------- Assign in Inspector ---------
@serializable() mainUrl: string = "/needle/mainContent/assets/MainScene.glb";
@serializable(GameObject) parent?: GameObject; // where Main is instantiated
@serializable(GameObject) bootCube?: GameObject; // Boot cube (Transform)
/** Scrub head 0..progressFrameCount during loading (e.g. 0..100f) */
@serializable(PlayableDirector) progressDirector?: PlayableDirector;
/** Total frames authored for the progress timeline (default 100 for 0f..100f) */
@serializable(Number) progressFrameCount: number = 100;
/** FPS used by that timeline (convert frames→seconds). Default 60. */
@serializable(Number) progressTimelineFPS: number = 60;
/** Plays once loading has finished */
@serializable(PlayableDirector) afterLoadDirector?: PlayableDirector;
/** Optional delay (ms) before starting the after-load timeline */
@serializable(Number) afterLoadDelayMs: number = 0;
// --------- Tuning ---------
@serializable() minScale: number = 0.001; // start scale (uniform)
@serializable() removeCubeOnDone: boolean = false; // remove cube after load
// --------- internals ---------
private shown = 0; // eased progress 0..1
private finished = false;
private fakeRAF = 0;
start() {
console.log("[MainSceneLoader] start");
// Initialize visuals
this.applySceneProgress(0);
// Normalize URL and create the reference
const url = new URL(this.mainUrl, location.origin).toString();
const ref = new AssetReference({ url, context: this.context });
// Smooth fake progress so we never sit at 0 on dev servers
this.startFake();
// Listen for real byte progress (if server provides Content-Length)
ref.beginListenDownload((loaded: number, total: number) => {
if (total > 0) {
const raw = loaded / total;
this.shown += (raw - this.shown) * 0.25; // ease
const t = Math.min(0.99, this.shown); // reserve 1.0 for done
this.applySceneProgress(t);
}
});
// Load & instantiate
ref
.loadAssetAsync()
.then(() => ref.instantiate(this.parent ?? this.gameObject))
.then(() => this.finish())
.catch((err) => {
console.error("[MainSceneLoader] failed:", err);
this.stopFake();
});
}
// ---------- finish / fake ----------
private finish() {
this.finished = true;
this.stopFake();
this.applySceneProgress(1);
// Play the after-load director (with optional delay)
if (this.afterLoadDirector) {
const delay = Math.max(0, this.afterLoadDelayMs | 0);
setTimeout(() => {
const d: any = this.afterLoadDirector!;
try {
if (typeof d.time === "number") d.time = 0;
} catch {}
try {
if ("playbackSpeed" in d) d.playbackSpeed = 1;
else if ("speed" in d) d.speed = 1;
} catch {}
try {
d.play?.();
} catch {}
console.log("[MainSceneLoader] After-load director: started", {
delayMs: delay,
});
}, delay);
}
if (this.removeCubeOnDone) this.bootCube?.parent?.remove(this.bootCube);
// optional: notify parent (Astro) for slogan timing
try {
parent?.postMessage?.({ type: "needle:assets:loaded" }, location.origin);
} catch {}
}
private startFake() {
const loop = () => {
if (this.finished) return;
// ease towards ~0.9 (never reaches 1.0 until finish())
const target = Math.max(this.shown, 0.9);
this.shown += (target - this.shown) * 0.04; // tweak speed here
const t = Math.min(0.99, this.shown);
this.applySceneProgress(t);
this.fakeRAF = requestAnimationFrame(loop);
};
this.fakeRAF = requestAnimationFrame(loop);
}
private stopFake() {
if (this.fakeRAF) cancelAnimationFrame(this.fakeRAF);
this.fakeRAF = 0;
}
// ---------- visuals ----------
/** Applies all scene feedback driven by progress t ∈ [0..1] */
private applySceneProgress(t: number) {
// 1) Boot cube scale (0.001 → 1)
const cube = this.bootCube ?? this.gameObject;
if (cube) {
const s = this.minScale + t * (1 - this.minScale);
cube.scale.set(s, s, s);
cube.updateMatrixWorld(true);
}
// 2) Scrub PlayableDirector to match loading percent in FRAMES (0..progressFrameCount)
// Example: 70% → frame 70 → seconds = 70 / FPS
if (
this.progressDirector &&
Number.isFinite(this.progressFrameCount) &&
Number.isFinite(this.progressTimelineFPS) &&
this.progressTimelineFPS > 0
) {
const clamped = Math.max(0, Math.min(1, t));
const frame = clamped * this.progressFrameCount;
const seconds = frame / this.progressTimelineFPS;
const d: any = this.progressDirector;
try {
if (typeof d.time === "number") d.time = seconds;
} catch {}
// Evaluate the timeline at that time and keep it paused
try {
d.play?.();
} catch {}
try {
d.evaluate?.();
} catch {}
try {
if ("playbackSpeed" in d) d.playbackSpeed = 0;
else if ("speed" in d) d.speed = 0;
} catch {}
}
}
}
Thanks for the code snippet. Looks fine to me. Do you have a link to the website online somewhere I could check out? Feel free to DM if it’s not ready to be publicly shared yet
The issue is caused by instantiate(...) in the custom loading script. Instantiate does internally create a clone of the scene in which case timeline bindings resolve to the wrong (original) object which is a bug in Needle Engine (tracked as NE-6778)
Changing the code above to use loadAssetAsync() (see below) fixes the issue for now:
// Load & instantiate
ref
.loadAssetAsync()
.then((root) => {
if (!root) throw new Error("loadAssetAsync() returned null");
// If a parent is assigned in the Inspector, attach the loaded root under it.
// Otherwise it stays where Needle spawns it (scene root).
const parent = this.parent ?? this.gameObject;
if (parent && root.parent !== parent) parent.add(root);
return root;
})
.then(() => this.finish())
Hi @Ilja_Burzev just wanted to let you know that this particular issue should be fixed in one of the next updated.
The issue we found wasn’t actually related to object bindings but an error where the cloned timeline component simply didn’t evaluate the animation track (hence it didn’t animate).