@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in
1,319 lines (1,165 loc) • 60.8 kB
text/typescript
import 'three/examples/jsm/renderers/webgl-legacy/nodes/WebGLNodes.js';
import type { EffectComposer } from "postprocessing";
import {
BufferGeometry, Camera, Color, DepthTexture, Group,
Material, NearestFilter, NoToneMapping, Object3D, OrthographicCamera, PCFSoftShadowMap,
PerspectiveCamera, RGBAFormat, Scene, SRGBColorSpace,
Texture, WebGLRenderer, type WebGLRendererParameters, WebGLRenderTarget, type WebXRArrayCamera
} from 'three';
/** @ts-ignore (not yet in types?) */
import { BasicNodeLibrary } from "three";
import * as Stats from 'three/examples/jsm/libs/stats.module.js';
import type { EffectComposer as ThreeEffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { nodeFrame } from "three/examples/jsm/renderers/webgl-legacy/nodes/WebGLNodeBuilder.js";
import { isDevEnvironment, LogType, showBalloonError, showBalloonMessage } from './debug/index.js';
import { Addressables } from './engine_addressables.js';
import { AnimationsRegistry } from './engine_animation.js';
import { Application } from './engine_application.js';
import { AssetDatabase } from './engine_assetdatabase.js';
import { VERSION } from './engine_constants.js';
import { ContextEvent, ContextRegistry } from './engine_context_registry.js';
import { WaitForPromise } from './engine_coroutine.js';
import { ObjectUtils } from "./engine_create_objects.js";
import { destroy, foreachComponent } from './engine_gameobject.js';
import { getLoader } from './engine_gltf.js';
import { Input } from './engine_input.js';
import { invokeLifecycleFunctions } from './engine_lifecycle_functions_internal.js';
import { type ILightDataRegistry, LightDataRegistry } from './engine_lightdata.js';
import { LODsManager } from "./engine_lods.js";
import * as looputils from './engine_mainloop_utils.js';
import { NetworkConnection } from './engine_networking.js';
import { Physics } from './engine_physics.js';
import { PlayerViewManager } from './engine_playerview.js';
import { RendererData as SceneLighting } from './engine_scenelighting.js';
import { logHierarchy } from './engine_three_utils.js';
import { Time } from './engine_time.js';
import { patchTonemapping } from './engine_tonemapping.js';
import type { CoroutineData, ICamera, IComponent, IContext, ILight, LoadedModel, Model, Vec2 } from "./engine_types.js";
import { deepClone, delay, DeviceUtilities, getParam } from './engine_utils.js';
import type { INeedleXRSessionEventReceiver, NeedleXRSession } from './engine_xr.js';
import { NeedleMenu } from './webcomponents/needle menu/needle-menu.js';
const debug = getParam("debugcontext");
const stats = getParam("stats");
const debugActive = getParam("debugactive");
const debugframerate = getParam("debugframerate");
const debugCoroutine = getParam("debugcoroutine");
// this is where functions that setup unity scenes will be pushed into
// those will be accessed from our custom html element to load them into their context
export const build_scene_functions: { [name: string]: (context: Context) => Promise<void> } = {};
export declare class LoadingProgressArgs {
/** the name or URL of the loaded file */
name: string;
/** the loading progress event from the loader */
progress: ProgressEvent;
/** the index of the loaded file */
index: number;
/** the total number of files to load */
count: number;
}
export declare class ContextCreateArgs {
/** list of glTF or GLB files to load */
files: Array<string>;
abortSignal?: AbortSignal;
/** called when loading a provided glTF file started */
onLoadingStart?: (index: number, file: string) => void;
/** called on update for each loaded glTF file */
onLoadingProgress?: (args: LoadingProgressArgs) => void;
/** Called after a gLTF file has finished loading */
onLoadingFinished?: (index: number, file: string, glTF: Model | null) => void;
}
export class ContextArgs {
name?: string;
/** for debugging only */
alias?: string;
/** the hash is used as a seed when initially loading the scene files */
hash?: string;
/** when true the context will not check if it's visible in the viewport and always update and render */
runInBackground?: boolean;
/** the DOM element the context belongs to or is inside of (this does not have to be the canvas. use renderer.domElement if you want to access the dom canvas) */
domElement?: HTMLElement | null;
/** externally owned renderer */
renderer?: WebGLRenderer;
/** externally owned camera */
camera?: Camera;
/** externally owned scene */
scene?: Scene;
}
export enum FrameEvent {
Start = -1,
EarlyUpdate = 0,
Update = 1,
LateUpdate = 2,
OnBeforeRender = 3,
OnAfterRender = 4,
PrePhysicsStep = 9,
PostPhysicsStep = 10,
Undefined = -1,
}
/** threejs callback event signature */
export declare type OnRenderCallback = (renderer: WebGLRenderer, scene: Scene, camera: Camera, geometry: BufferGeometry, material: Material, group: Group) => void
export function registerComponent(script: IComponent, context?: Context) {
if (!script) return;
if (!script.isComponent) {
if (isDevEnvironment() || debug)
console.error("Registered script is not a Needle Engine component. \nThe script will be ignored. Please make sure your component extends \"Behaviour\" imported from \"@needle-tools/engine\"\n", script);
return;
}
if (!context) {
context = Context.Current;
if (debug) console.warn("> Registering component without context");
}
const new_scripts = context?.new_scripts;
if (!new_scripts.includes(script)) {
new_scripts.push(script);
}
}
/**
* The context is the main object that holds all the data and state of the Needle Engine.
* It can be used to access the scene, renderer, camera, input, physics, networking, and more.
* @example
* ```typescript
* import { Behaviour } from "@needle-tools/engine";
* import { Mesh, BoxGeometry, MeshBasicMaterial } from "three";
* export class MyScript extends Behaviour {
* start() {
* console.log("Hello from MyScript");
* this.context.scene.add(new Mesh(new BoxGeometry(), new MeshBasicMaterial()));
* }
* }
* ```
*/
export class Context implements IContext {
private static _defaultTargetFramerate: { value?: number, toString?() } = { value: 90, toString() { return this.value; } }
/** When a new context is created this is the framerate that will be used by default */
static get DefaultTargetFrameRate(): number | undefined {
return Context._defaultTargetFramerate.value;
}
/** When a new context is created this is the framerate that will be used by default */
static set DefaultTargetFrameRate(val: number | undefined) {
Context._defaultTargetFramerate.value = val;
}
private static _defaultWebglRendererParameters: WebGLRendererParameters = {
antialias: true,
alpha: false,
// Note: this is due to a bug on OSX devices. See NE-5370
powerPreference: (DeviceUtilities.isiOS() || DeviceUtilities.isMacOS()) ? "default" : "high-performance",
stencil: true,
// logarithmicDepthBuffer: true,
// reverseDepthBuffer: true, // https://github.com/mrdoob/three.js/issues/29770
};
/** The default parameters that will be used when creating a new WebGLRenderer.
* Modify in global context to change the default parameters for all new contexts.
* @example
* ```typescript
* import { Context } from "@needle-tools/engine";
* Context.DefaultWebGLRendererParameters.antialias = false;
* ```
*/
static get DefaultWebGLRendererParameters(): WebGLRendererParameters {
return Context._defaultWebglRendererParameters;
}
/** The needle engine version */
get version() {
return VERSION;
}
/** The currently active context. Only set during the update loops */
static get Current(): Context {
return ContextRegistry.Current as Context;
}
/** @internal this property should not be set by user code */
static set Current(context: Context) {
ContextRegistry.Current = context;
}
static get All(): Context[] {
return ContextRegistry.All as Context[];
}
/** The name of the context */
name: string;
/** An alias for the context */
alias: string | undefined | null;
/** When the renderer or camera are managed by an external process (e.g. when running in r3f context).
* When this is false you are responsible to call update(timestamp, xframe.
* It is also currently assumed that rendering is handled performed by an external process
* */
isManagedExternally: boolean = false;
/** set to true to pause the update loop. You can receive an event for it in your components.
* Note that script updates will not be called when paused */
isPaused: boolean = false;
/** When enabled the application will run while not visible on the page */
runInBackground: boolean = false;
/**
* Set to the target framerate you want your application to run in (you can use ?stats to check the fps)
* Set to undefined if you want to run at the maximum framerate
*/
targetFrameRate?: number | { value?: number };
/** Use a higher number for more accurate physics simulation.
* When undefined physics steps will be 1 for mobile devices and 5 for desktop devices
* Set to 0 to disable physics updates
* TODO: changing physics steps is currently not supported because then forces that we get from the character controller and rigidbody et al are not correct anymore - this needs to be properly tested before making this configureable
*/
private physicsSteps?: number = 1;
/** used to append to loaded assets */
hash?: string;
/** The `<needle-engine>` web component */
domElement: HTMLElement;
appendHTMLElement(element: HTMLElement) {
if (this.domElement.shadowRoot)
return this.domElement.shadowRoot.appendChild(element);
else return this.domElement.appendChild(element);
}
get resolutionScaleFactor() { return this._resolutionScaleFactor; }
/** use to scale the resolution up or down of the renderer. default is 1 */
set resolutionScaleFactor(val: number) {
if (val === this._resolutionScaleFactor) return;
if (typeof val !== "number") return;
if (val <= 0) {
console.error("Invalid resolution scale factor", val);
return;
}
this._resolutionScaleFactor = val;
this.updateSize();
}
private _resolutionScaleFactor: number = 1;
// domElement.clientLeft etc doesnt return absolute position
private _boundingClientRectFrame: number = -1;
private _boundingClientRect: DOMRect | null = null;
private _domX; private _domY;
/** update bounding rects + domX, domY */
private calculateBoundingClientRect() {
// workaround for mozilla webXR viewer
if (this.xr) {
this._domX = 0;
this._domY = 0;
return;
}
// TODO: cache this
if (this._boundingClientRectFrame === this.time.frame) return;
this._boundingClientRectFrame = this.time.frame;
this._boundingClientRect = this.domElement.getBoundingClientRect();
this._domX = this._boundingClientRect.x;
this._domY = this._boundingClientRect.y;
}
/** The width of the `<needle-engine>` element on the website */
get domWidth(): number {
// for mozilla XR
if (this.isInAR) return window.innerWidth;
return this.domElement.clientWidth;
}
/** The height of the `<needle-engine>` element on the website */
get domHeight(): number {
// for mozilla XR
if (this.isInAR) return window.innerHeight;
return this.domElement.clientHeight;
}
/** the X position of the Needle Engine element on the website */
get domX(): number {
this.calculateBoundingClientRect();
return this._domX;
}
/** the Y position of the Needlee Engine element on the website */
get domY(): number {
this.calculateBoundingClientRect();
return this._domY;
}
get isInXR() { return this.renderer?.xr?.isPresenting || false; }
/** shorthand for `NeedleXRSession.active`
* Automatically set by NeedleXRSession when a XR session is active
* @returns the active XR session or null if no session is active
* */
xr: NeedleXRSession | null = null;
get xrSessionMode() { return this.xr?.mode; }
get isInVR() { return this.xrSessionMode === "immersive-vr"; }
get isInAR() { return this.xrSessionMode === "immersive-ar"; }
/** If a XR session is active and in pass through mode (immersive-ar on e.g. Quest) */
get isInPassThrough() { return this.xr ? this.xr.isPassThrough : false; }
/** access the raw `XRSession` object (shorthand for `context.renderer.xr.getSession()`). For more control use `NeedleXRSession.active` */
get xrSession() { return this.renderer?.xr?.getSession(); }
/** @returns the latest XRFrame (if a XRSession is currently active)
* @link https://developer.mozilla.org/en-US/docs/Web/API/XRFrame
*/
get xrFrame() { return this._xrFrame }
/** @returns the current WebXR camera while the WebXRManager is active (shorthand for `context.renderer.xr.getCamera()`) */
get xrCamera(): WebXRArrayCamera | undefined { return this.renderer.xr.isPresenting ? this.renderer?.xr?.getCamera() : undefined }
private _xrFrame: XRFrame | null = null;
get arOverlayElement(): HTMLElement {
const el = this.domElement as any;
if (typeof el.getAROverlayContainer === "function")
return el.getAROverlayContainer();
return this.domElement;
}
/** Current event of the update cycle */
get currentFrameEvent(): FrameEvent {
return this._currentFrameEvent;
}
private _currentFrameEvent: FrameEvent = FrameEvent.Undefined;
scene: Scene;
renderer!: WebGLRenderer;
composer: EffectComposer | ThreeEffectComposer | null = null;
// all scripts
readonly scripts: IComponent[] = [];
readonly scripts_pausedChanged: IComponent[] = [];
// scripts with update event
readonly scripts_earlyUpdate: IComponent[] = [];
readonly scripts_update: IComponent[] = [];
readonly scripts_lateUpdate: IComponent[] = [];
readonly scripts_onBeforeRender: IComponent[] = [];
readonly scripts_onAfterRender: IComponent[] = [];
readonly scripts_WithCorroutines: IComponent[] = [];
readonly scripts_immersive_vr: INeedleXRSessionEventReceiver[] = [];
readonly scripts_immersive_ar: INeedleXRSessionEventReceiver[] = [];
readonly coroutines: { [FrameEvent: number]: Array<CoroutineData> } = {}
/** callbacks called once after the context has been created */
readonly post_setup_callbacks: Function[] = [];
/** called every frame at the beginning of the frame (after component start events and before earlyUpdate) */
readonly pre_update_callbacks: Function[] = [];
/** called every frame before rendering (after all component events) */
readonly pre_render_callbacks: Array<(frame: XRFrame | null) => void> = [];
/** called every frame after rendering (after all component events) */
readonly post_render_callbacks: Function[] = [];
/** called every frame befroe update (this list is emptied every frame) */
readonly pre_update_oneshot_callbacks: Function[] = [];
readonly new_scripts: IComponent[] = [];
readonly new_script_start: IComponent[] = [];
readonly new_scripts_pre_setup_callbacks: Function[] = [];
readonly new_scripts_post_setup_callbacks: Function[] = [];
readonly new_scripts_xr: INeedleXRSessionEventReceiver[] = [];
/** The main camera component of the scene - this camera is used for rendering */
mainCameraComponent: ICamera | undefined = undefined;
/** The main camera of the scene - this camera is used for rendering */
get mainCamera(): Camera {
if (this._mainCamera) {
return this._mainCamera;
}
if (this.mainCameraComponent) {
const cam = this.mainCameraComponent as ICamera;
if (!cam.threeCamera)
cam.buildCamera();
return cam.threeCamera;
}
if (!this._fallbackCamera) {
this._fallbackCamera = new PerspectiveCamera(75, this.domWidth / this.domHeight, 0.1, 1000);
}
return this._fallbackCamera;
}
/** Set the main camera of the scene. If set to null the camera of the {@link mainCameraComponent} will be used - this camera is used for rendering */
set mainCamera(cam: Camera | null) {
this._mainCamera = cam;
}
private _mainCamera: Camera | null = null;
private _fallbackCamera: PerspectiveCamera | null = null;
application: Application;
/** access animation mixer used by components in the scene */
animations: AnimationsRegistry;
/** access timings (current frame number, deltaTime, timeScale, ...) */
time: Time;
input: Input;
/** access physics related methods (e.g. raycasting). To access the phyiscs engine use `context.physics.engine` */
physics: Physics;
/** access networking methods (use it to send or listen to messages or join a networking backend) */
connection: NetworkConnection;
/**
* @deprecated AssetDataBase is deprecated
*/
assets: AssetDatabase;
mainLight: ILight | null = null;
/** @deprecated Use sceneLighting */
get rendererData() { return this.sceneLighting }
sceneLighting: SceneLighting;
addressables: Addressables;
lightmaps: ILightDataRegistry;
players: PlayerViewManager;
readonly lodsManager: LODsManager;
readonly menu: NeedleMenu;
get isCreated() { return this._isCreated; }
private _sizeChanged: boolean = false;
private _isCreated: boolean = false;
private _isCreating: boolean = false;
private _isVisible: boolean = false;
private _stats = stats ? new Stats.default() : null;
constructor(args?: ContextArgs) {
this.name = args?.name || "";
this.alias = args?.alias;
this.domElement = args?.domElement || document.body;
this.hash = args?.hash;
if (args?.renderer) {
this.renderer = args.renderer;
this.isManagedExternally = true;
}
if (args?.runInBackground !== undefined) this.runInBackground = args.runInBackground;
if (args?.scene) this.scene = args.scene;
else this.scene = new Scene();
if (args?.camera) this._mainCamera = args.camera;
this.application = new Application(this);
this.time = new Time();
this.input = new Input(this);
this.physics = new Physics(this);
this.connection = new NetworkConnection(this);
// eslint-disable-next-line deprecation/deprecation
this.assets = new AssetDatabase();
this.sceneLighting = new SceneLighting(this);
this.addressables = new Addressables(this);
this.lightmaps = new LightDataRegistry(this);
this.players = new PlayerViewManager(this);
this.menu = new NeedleMenu(this);
this.lodsManager = new LODsManager(this);
this.animations = new AnimationsRegistry(this);
const resizeCallback = () => this._sizeChanged = true;
window.addEventListener('resize', resizeCallback);
this._disposeCallbacks.push(() => window.removeEventListener('resize', resizeCallback));
const resizeObserver = new ResizeObserver(_ => this._sizeChanged = true);
resizeObserver.observe(this.domElement);
this._disposeCallbacks.push(() => resizeObserver.disconnect());
this._intersectionObserver = new IntersectionObserver(entries => {
this._isVisible = entries[0].isIntersecting;
});
this._disposeCallbacks.push(() => this._intersectionObserver?.disconnect());
ContextRegistry.register(this);
}
/** calling this function will dispose the current renderer and create a new one */
createNewRenderer(params?: WebGLRendererParameters) {
this.renderer?.dispose();
params = { ...Context.DefaultWebGLRendererParameters, ...params };
if (!params.canvas) {
// get canvas already configured in the Needle Engine Web Component
const canvas = this.domElement?.shadowRoot?.querySelector("canvas");
if (canvas) {
params.canvas = canvas;
if (debug) {
console.log("Using canvas from shadow root", canvas);
}
}
}
if (debug) console.log("Using Renderer Parameters:", params, this.domElement)
this.renderer = new WebGLRenderer(params);
this.renderer.debug.checkShaderErrors = isDevEnvironment() || getParam("checkshadererrors") === true;
// some tonemapping other than "NONE" is required for adjusting exposure with EXR environments
this.renderer.toneMappingExposure = 1; // range [0...inf] instead of the usual -15..15
this.renderer.toneMapping = NoToneMapping; // could also set to LinearToneMapping, ACESFilmicToneMapping
this.renderer.setClearColor(new Color('lightgrey'), 0);
// // @ts-ignore
// this.renderer.alpha = false;
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = PCFSoftShadowMap;
this.renderer.setSize(this.domWidth, this.domHeight);
this.renderer.outputColorSpace = SRGBColorSpace;
// Injecting the core nodes library here, like WebGPURenderer backends do
//@ts-ignore
this.renderer.nodes = {
library: new BasicNodeLibrary(),
modelViewMatrix: null,
modelNormalViewMatrix: null,
};
// this.renderer.toneMapping = AgXToneMapping;
this.lodsManager.setRenderer(this.renderer);
this.input.bindEvents();
}
private _intersectionObserver: IntersectionObserver | null = null;
private internalOnUpdateVisible() {
this._intersectionObserver?.disconnect();
this._intersectionObserver?.observe(this.domElement);
}
private _disposeCallbacks: Function[] = [];
/** will request a renderer size update the next render call (will call updateSize the next update) */
requestSizeUpdate() { this._sizeChanged = true; }
/** Clamps the renderer max resolution. If undefined the max resolution is not clamped. Default is undefined */
maxRenderResolution?: Vec2;
/** update the renderer and canvas size */
updateSize(force: boolean = false) {
if (force || (!this.isManagedExternally && this.renderer.xr?.isPresenting === false)) {
this._sizeChanged = false;
const scaleFactor = this.resolutionScaleFactor;
let width = this.domWidth * scaleFactor;
let height = this.domHeight * scaleFactor;
if (this.maxRenderResolution) {
this.maxRenderResolution.x = Math.max(1, this.maxRenderResolution.x);
width = Math.min(this.maxRenderResolution.x, width);
this.maxRenderResolution.y = Math.max(1, this.maxRenderResolution.y);
height = Math.min(this.maxRenderResolution.y, height);
}
const camera = this.mainCamera as PerspectiveCamera;
this.updateAspect(camera);
this.renderer.setSize(width, height, true);
this.renderer.setPixelRatio(window.devicePixelRatio);
// avoid setting pixel values here since this can cause pingpong updates
// e.g. when system scale is set to 125%
// https://github.com/needle-tools/needle-engine-support/issues/69
this.renderer.domElement.style.width = "100%";
this.renderer.domElement.style.height = "100%";
if (this.composer) {
this.composer.setSize?.call(this.composer, width, height);
if ("setPixelRatio" in this.composer && typeof this.composer.setPixelRatio === "function")
this.composer.setPixelRatio?.call(this.composer, window.devicePixelRatio);
}
}
}
updateAspect(camera: PerspectiveCamera | OrthographicCamera, width?: number, height?: number) {
if (!camera) return;
if (width === undefined)
width = this.domWidth;
if (height === undefined)
height = this.domHeight;
const aspectRatio = width / height;
if ((camera as PerspectiveCamera).isPerspectiveCamera) {
const cam = camera as PerspectiveCamera;
const pa = cam.aspect;
cam.aspect = aspectRatio;
if (pa !== cam.aspect)
camera.updateProjectionMatrix();
}
else if ((camera as OrthographicCamera).isOrthographicCamera) {
const cam = camera as OrthographicCamera;
// Maintain the camera's current vertical size (top - bottom)
const verticalSize = cam.top - cam.bottom;
// Calculate new horizontal size based on aspect ratio
const horizontalSize = verticalSize * aspectRatio;
// Update camera bounds while maintaining center position
const halfWidth = horizontalSize / 2;
const halfHeight = verticalSize / 2;
if (cam.left != -halfWidth || cam.top != halfHeight) {
cam.left = -halfWidth;
cam.right = halfWidth;
cam.top = halfHeight;
cam.bottom = -halfHeight;
camera.updateProjectionMatrix();
}
}
}
/** This will recreate the whole needle engine context and dispose the whole scene content
* All content will be reloaded (loading times might be faster due to browser caches)
* All scripts will be recreated */
recreate() {
this.clear();
this.create(this._originalCreationArgs);
}
private _originalCreationArgs?: ContextCreateArgs;
/** @deprecated use create. This method will be removed in a future version */
async onCreate(opts?: ContextCreateArgs) {
return this.create(opts);
}
async create(opts?: ContextCreateArgs) {
try {
this._isCreating = true;
if (opts !== this._originalCreationArgs)
this._originalCreationArgs = deepClone(opts);
window.addEventListener("unhandledrejection", this.onUnhandledRejection)
const res = await this.internalOnCreate(opts);
this._isCreated = res;
return res;
}
finally {
window.removeEventListener("unhandledrejection", this.onUnhandledRejection)
this._isCreating = false;
}
}
private onUnhandledRejection = (event: PromiseRejectionEvent) => {
this.onError(event.reason);
};
/** Dispatches an error */
private onError(error: string) {
this.domElement.dispatchEvent(new CustomEvent("error", { detail: error }));
}
/** Will destroy all scenes and objects in the scene
*/
clear() {
ContextRegistry.dispatchCallback(ContextEvent.ContextClearing, this);
invokeLifecycleFunctions(this, ContextEvent.ContextClearing);
// NOTE: this does dispose the environment/background image too
// which is probably not desired if it is set via the skybox-image attribute
destroy(this.scene, true, true);
this.scene = new Scene();
this.addressables?.dispose();
this.lightmaps?.clear();
this.physics?.engine?.clearCaches();
this.lodsManager.disable();
if (!this.isManagedExternally) {
if (this.renderer) {
this.renderer.renderLists.dispose();
this.renderer.state.reset();
this.renderer.resetState();
}
}
// We do not want to clear the renderer here because when switching src we want to keep the last rendered frame in case the loading screen is not visible
// if a user wants to see the background they can still call setClearAlpha(0) and clear manually
ContextRegistry.dispatchCallback(ContextEvent.ContextCleared, this);
}
dispose() {
this.internalOnDestroy();
}
/**@deprecated use dispose() */
onDestroy() { this.internalOnDestroy(); }
private internalOnDestroy() {
Context.Current = this;
ContextRegistry.dispatchCallback(ContextEvent.ContextDestroying, this);
invokeLifecycleFunctions(this, ContextEvent.ContextDestroying);
this.clear();
this.renderer?.setAnimationLoop(null);
if (this.renderer) {
this.renderer.setClearAlpha(0);
this.renderer.clear();
if (!this.isManagedExternally) {
if (debug) console.log("Disposing renderer");
this.renderer.dispose();
}
}
this.scene = null!;
this.renderer = null!;
this.input.dispose();
this.menu.onDestroy();
this.animations.onDestroy();
for (const cb of this._disposeCallbacks) {
try {
cb();
}
catch (e) {
console.error("Error in on dispose callback:", e, cb);
}
}
if (this.domElement?.parentElement) {
this.domElement.parentElement.removeChild(this.domElement);
}
this._isCreated = false;
ContextRegistry.dispatchCallback(ContextEvent.ContextDestroyed, this);
invokeLifecycleFunctions(this, ContextEvent.ContextDestroyed);
ContextRegistry.unregister(this);
if (Context.Current === this) {
//@ts-ignore
Context.Current = null;
}
}
registerCoroutineUpdate(script: IComponent, coroutine: Generator, evt: FrameEvent): Generator {
if (typeof coroutine?.next !== "function") {
console.error("Registered invalid coroutine function from " + script.name + "\nCoroutine functions must be generators: \"*myCoroutine() {...}\"\nStart a coroutine from a component by calling \"this.startCoroutine(myCoroutine())\"")
return coroutine;
}
if (!this.coroutines[evt]) this.coroutines[evt] = [];
this.coroutines[evt].push({ comp: script, main: coroutine });
return coroutine;
}
unregisterCoroutineUpdate(coroutine: Generator, evt: FrameEvent): void {
if (!this.coroutines[evt]) return;
const idx = this.coroutines[evt].findIndex(c => c.main === coroutine);
if (idx >= 0) this.coroutines[evt].splice(idx, 1);
}
stopAllCoroutinesFrom(script: IComponent) {
for (const evt in this.coroutines) {
const rout: CoroutineData[] = this.coroutines[evt];
for (let i = rout.length - 1; i >= 0; i--) {
const r = rout[i];
if (r.comp === script) {
rout.splice(i, 1);
}
}
}
}
private _cameraStack: ICamera[] = [];
setCurrentCamera(cam: ICamera) {
if (!cam) return;
if (!cam.threeCamera) cam.buildCamera(); // < to build camera
if (!cam.threeCamera) {
console.warn("Camera component is missing camera", cam)
return;
}
const index = this._cameraStack.indexOf(cam);
if (index >= 0) this._cameraStack.splice(index, 1);
this._cameraStack.push(cam);
this.mainCameraComponent = cam;
const camera = cam.threeCamera as PerspectiveCamera;
if (camera.isPerspectiveCamera)
this.updateAspect(camera);
(this.mainCameraComponent as ICamera)?.applyClearFlagsIfIsActiveCamera();
}
removeCamera(cam?: ICamera | null) {
if (!cam) return;
const index = this._cameraStack.indexOf(cam);
if (index >= 0) this._cameraStack.splice(index, 1);
if (this.mainCameraComponent === cam) {
this.mainCameraComponent = undefined;
if (this._cameraStack.length > 0) {
const last = this._cameraStack[this._cameraStack.length - 1];
this.setCurrentCamera(last);
}
}
}
private _onBeforeRenderListeners = new Map<string, OnRenderCallback[]>();
private _onAfterRenderListeners = new Map<string, OnRenderCallback[]>();
/** use this to subscribe to onBeforeRender events on threejs objects */
addBeforeRenderListener(target: Object3D, callback: OnRenderCallback) {
if (!this._onBeforeRenderListeners.has(target.uuid)) {
this._onBeforeRenderListeners.set(target.uuid, []);
target.onBeforeRender = this._createRenderCallbackWrapper(target, this._onBeforeRenderListeners);
}
this._onBeforeRenderListeners.get(target.uuid)!.push(callback);
}
removeBeforeRenderListener(target: Object3D, callback: OnRenderCallback) {
if (this._onBeforeRenderListeners.has(target.uuid)) {
const arr = this._onBeforeRenderListeners.get(target.uuid)!;
const idx = arr.indexOf(callback);
if (idx >= 0) arr.splice(idx, 1);
}
}
/** use this to subscribe to onAfterRender events on threejs objects */
addAfterRenderListener(target: Object3D, callback: OnRenderCallback) {
if (!this._onAfterRenderListeners.has(target.uuid)) {
this._onAfterRenderListeners.set(target.uuid, []);
target.onAfterRender = this._createRenderCallbackWrapper(target, this._onAfterRenderListeners);
}
this._onAfterRenderListeners.get(target.uuid)?.push(callback);
}
removeAfterRenderListener(target: Object3D, callback: OnRenderCallback) {
if (this._onAfterRenderListeners.has(target.uuid)) {
const arr = this._onAfterRenderListeners.get(target.uuid)!;
const idx = arr.indexOf(callback);
if (idx >= 0) arr.splice(idx, 1);
}
}
private _createRenderCallbackWrapper(target: Object3D, array: Map<string, OnRenderCallback[]>): OnRenderCallback {
return (renderer, scene, camera, geometry, material, group) => {
const arr = array.get(target.uuid);
if (!arr) return;
for (let i = 0; i < arr.length; i++) {
const fn = arr[i];
fn(renderer, scene, camera, geometry, material, group);
}
}
}
private _requireDepthTexture: boolean = false;
private _requireColorTexture: boolean = false;
private _renderTarget?: WebGLRenderTarget;
private _isRendering: boolean = false;
get isRendering() { return this._isRendering; }
setRequireDepth(val: boolean) {
this._requireDepthTexture = val;
}
setRequireColor(val: boolean) {
this._requireColorTexture = val;
}
get depthTexture(): DepthTexture | null {
return this._renderTarget?.depthTexture || null;
}
get opaqueColorTexture(): Texture | null {
return this._renderTarget?.texture || null;
}
/** returns true if the dom element is visible on screen */
get isVisibleToUser() {
if (this.isInXR) return true;
if (!this._isVisible) return false;
const style = getComputedStyle(this.domElement);
return style.visibility !== "hidden" && style.display !== "none" && style.opacity !== "0";
}
private _createId: number = 0;
private async internalOnCreate(opts?: ContextCreateArgs): Promise<boolean> {
const createId = ++this._createId;
if (debug) console.log("Creating context", this.name, opts);
// wait for async imported dependencies to be loaded
// see https://linear.app/needle/issue/NE-4445
const dependenciesReady = globalThis["needle:dependencies:ready"];
if (dependenciesReady instanceof Promise) {
if (debug) console.log("Waiting for dependencies to be ready");
await dependenciesReady
.catch(err => {
if (debug || isDevEnvironment()) {
showBalloonError("Needle Engine dependencies failed to load. Please check the console for more details");
const printedError = false;
if (err instanceof ReferenceError) {
let offendingComponentName = "YourComponentName";
const offendingComponentStartIndex = err.message.indexOf("'");
if (offendingComponentStartIndex > 0) {
const offendingComponentEndIndex = err.message.indexOf("'", offendingComponentStartIndex + 1);
if (offendingComponentEndIndex > 0) {
const name = err.message.substring(offendingComponentStartIndex + 1, offendingComponentEndIndex);
if (name.length > 3) offendingComponentName = name;
}
}
console.error(`Needle Engine dependencies failed to load:\n\n# Make sure you don't have circular imports in your scripts!\n\nPossible solutions: \n→ Replace @serializable(${offendingComponentName}) in your script with @serializable(Behaviour)\n→ If you only need type information try importing the type only, e.g: import { type ${offendingComponentName} }\n\n---`, err)
return;
}
if (!printedError) {
console.error("Needle Engine dependencies failed to load", err);
}
}
})
.then(() => {
if (debug) console.log("Needle Engine dependencies are ready");
});
}
this.clear();
// stop the animation loop if its running during creation
// since we do not want to start enabling scripts etc before they are deserialized
if (this.isManagedExternally === false) {
this.createNewRenderer();
this.renderer?.setAnimationLoop(null);
}
await delay(1);
Context.Current = this;
await ContextRegistry.dispatchCallback(ContextEvent.ContextCreationStart, this);
// load and create scene
let prepare_succeeded = true;
let loadedFiles!: Array<LoadedModel | null>;
try {
Context.Current = this;
if (opts) {
loadedFiles = await this.internalLoadInitialContent(createId, opts);
}
else loadedFiles = [];
}
catch (err) {
console.error(err);
prepare_succeeded = false;
}
if (!prepare_succeeded) {
this.onError("Failed to load initial content");
return false;
}
if (createId !== this._createId || opts?.abortSignal?.aborted) {
return false;
}
this.internalOnUpdateVisible();
if (!this.renderer) {
if (debug) console.warn("Context has no renderer (perhaps it was disconnected?", this.domElement.isConnected);
return false;
}
if (!this.isManagedExternally && !this.domElement.shadowRoot) {
this.domElement.prepend(this.renderer.domElement);
}
Context.Current = this;
// TODO: we could configure if we need physics
// await this.physics.engine?.initialize();
// Setup
Context.Current = this;
for (let i = 0; i < this.new_scripts.length; i++) {
const script = this.new_scripts[i];
if (script.gameObject !== undefined && script.gameObject !== null) {
if (script.gameObject.userData === undefined) script.gameObject.userData = {};
if (script.gameObject.userData.components === undefined) script.gameObject.userData.components = [];
const arr = script.gameObject.userData.components;
if (!arr.includes(script)) arr.push(script);
}
// if (script.gameObject && !this.raycastTargets.includes(script.gameObject)) {
// this.raycastTargets.push(script.gameObject);
// }
}
// const context = new SerializationContext(this.scene);
// for (let i = 0; i < this.new_scripts.length; i++) {
// const script = this.new_scripts[i];
// const ser = script as unknown as ISerializable;
// if (ser.$serializedTypes === undefined) continue;
// context.context = this;
// context.object = script.gameObject;
// deserializeObject(ser, script, context);
// }
// resolve post setup callbacks (things that rely on threejs objects having references to components)
if (this.post_setup_callbacks) {
for (let i = 0; i < this.post_setup_callbacks.length; i++) {
Context.Current = this;
await this.post_setup_callbacks[i](this);
}
}
if (!this._mainCamera) {
Context.Current = this;
let camera: ICamera | null = null;
foreachComponent(this.scene, comp => {
const cam = comp as ICamera;
if (cam?.isCamera) {
looputils.updateActiveInHierarchyWithoutEventCall(cam.gameObject);
if (!cam.activeAndEnabled) return undefined;
if (cam.tag === "MainCamera") {
camera = cam;
return true;
}
else camera = cam;
}
return undefined;
});
if (camera) {
this.setCurrentCamera(camera);
}
else {
const res = ContextRegistry.dispatchCallback(ContextEvent.MissingCamera, this, { files: loadedFiles });
if (!res && !this.mainCamera && !this.isManagedExternally)
console.warn("Missing camera in main scene", this);
}
}
this.input.bindEvents();
Context.Current = this;
looputils.processNewScripts(this);
// We have to step once so that colliders that have been created in onEnable can be raycasted in start
if (this.physics.engine) {
this.physics.engine?.step(0);
this.physics.engine?.postStep();
}
// const mainCam = this.mainCameraComponent as Camera;
// if (mainCam) {
// mainCam.applyClearFlagsIfIsActiveCamera();
// }
if (!this.isManagedExternally && this.composer && this.mainCamera) {
// TODO: import postprocessing async
// const renderPass = new RenderPass(this.scene, this.mainCamera);
// this.renderer.setSize(this.domWidth, this.domHeight);
// this.composer.addPass(renderPass);
// this.composer.setSize(this.domWidth, this.domHeight);
}
this._sizeChanged = true;
if (this._stats) {
this._stats.showPanel(0);
this._stats.dom.style.position = "absolute"; // (default is fixed)
this.domElement.shadowRoot?.appendChild(this._stats.dom);
}
if (debug)
logHierarchy(this.scene, true);
// If no target framerate was set we use the default
if (this.targetFrameRate === undefined) {
if (debug) console.warn("No target framerate set, using default", Context.DefaultTargetFrameRate);
// the _defaultTargetFramerate is intentionally an object so it can be changed at any time if not explictly set by the user
this.targetFrameRate = Context._defaultTargetFramerate;
}
else if (debug) console.log("Target framerate set to", this.targetFrameRate);
this._dispatchReadyAfterFrame = true;
const res = ContextRegistry.dispatchCallback(ContextEvent.ContextCreated, this, { files: loadedFiles });
if (res) {
if ("internalSetLoadingMessage" in this.domElement && typeof this.domElement.internalSetLoadingMessage === "function")
this.domElement?.internalSetLoadingMessage("finish loading");
await res;
}
if (opts?.abortSignal?.aborted) {
return false;
}
invokeLifecycleFunctions(this, ContextEvent.ContextCreated);
if (debug) console.log("Context Created...", this.renderer, this.renderer.domElement)
this._isCreating = false;
if (!this.isManagedExternally && !opts?.abortSignal?.aborted)
this.restartRenderLoop();
return true;
}
private async internalLoadInitialContent(createId: number, args: ContextCreateArgs): Promise<Array<LoadedModel>> {
const results = new Array<LoadedModel>();
// early out if we dont have any files to load
if (args.files.length === 0) return results;
const files = [...args.files];
const progressArg: LoadingProgressArgs = {
name: "",
progress: null!,
index: 0,
count: files.length
}
const loader = getLoader();
// this hash should be constant since it is used to initialize the UIDProvider per initially loaded scene
const loadingHash = 0;
for (let i = 0; i < files.length; i++) {
if (args.abortSignal?.aborted) {
if (debug) console.log("Aborting loading because of abort signal");
break;
}
// abort loading if the create id has changed
if (createId !== this._createId) {
if (debug) console.log("Aborting loading because create id changed", createId, this._createId);
break;
}
const file = files[i];
args?.onLoadingStart?.call(this, i, file);
if (debug) console.log("Context Load " + file);
const res = await loader.loadSync(this, file, file, loadingHash, prog => {
if (args.abortSignal?.aborted) return;
progressArg.name = file;
progressArg.progress = prog;
progressArg.index = i;
progressArg.count = files.length;
args.onLoadingProgress?.call(this, progressArg);
});
args?.onLoadingFinished?.call(this, i, file, res ?? null);
if (res) {
results.push({
src: file,
file: res
});
}
else {
// a file could not be loaded
console.warn("Could not load file: " + file);
}
}
// if the id was changed while still loading
// then we want to cleanup/destroy previously loaded files
if (createId !== this._createId || args.abortSignal?.aborted) {
if (debug) console.log("Aborting loading because create id changed or abort signal was set", createId, this._createId);
for (const res of results) {
if (res && res.file) {
for (const scene of res.file.scenes)
destroy(scene, true, true);
}
}
}
// otherwise we want to add the loaded files to the current scene
else {
let anyModelFound = false;
for (const res of results) {
if (res && res.file) {
// TODO: should we load all scenes in a glTF here?
if (res.file.scene) {
anyModelFound = true;
this.scene.add(res.file.scene);
}
else {
console.warn("No scene found in loaded file");
}
}
}
// If the loaded files do not contain ANY model
// We then attempt to create a mesh from each material in the loaded files to visualize it
// It's ok to do this at this point because we know the context has been cleared because the whole `src` attribute has been set
if (!anyModelFound) {
for (const res of results) {
if (res && res.file && "parser" in res.file) {
let y = 0;
if (!Array.isArray(res.file.parser.json.materials)) continue;
for (let i = 0; i < res.file.parser.json.materials.length; i++) {
const mat = await res.file.parser.getDependency("material", i);
const parent = new Object3D();
parent.position.x = i * 1.1;
parent.position.y = y;
this.scene.add(parent);
ObjectUtils.createPrimitive("ShaderBall", {
parent,
material: mat
});
}
y += 1;
}
}
}
}
return results;
}
/** Sets the animation loop.
* Can not be done while creating the context or when disposed
**/
public restartRenderLoop(): boolean {
if (!this.renderer) {
console.error("Can not start render loop without renderer");
return false;
}
if (this._isCreating) {
console.warn("Can not start render loop while creating context");
return false;
}
this.renderer.setAnimationLoop((timestamp, frame: XRFrame | null) => {
if (this.isManagedExternally) return;
this.update(timestamp, frame)
});
return true;
}
private _renderlooperrors = 0;
/** Performs a full update step including script callbacks, rendering (unless isManagedExternally is set to false) and post render callbacks */
public update(timestamp: DOMHighResTimeStamp, frame?: XRFrame | null) {
if (frame === undefined) frame = null;
if (isDevEnvironment() || debug || looputils.hasNewScripts()) {
try {
performance.mark('update.start');
this.internalStep(timestamp, frame);
this._renderlooperrors = 0;
performance.mark('update.end');
performance.measure('NE Frame', 'update.start', 'update.end');
}
catch (err) {
this._renderlooperrors += 1;
if ((isDevEnvironment() || debug) && (err instanceof Error || err instanceof TypeError))
showBalloonMessage(`Caught unhandled exception during render-loop - see console for details.`, LogType.Error);
console.error("Frame #" + this.time.frame + "\n", err);
if (this._renderlooperrors >= 3) {
console.warn("Stopping render loop due to error")
this.renderer.setAnimationLoop(null);
}
this.domElement.dispatchEvent(new CustomEvent("error", { detail: err }));
}
}
else {
this.internalStep(timestamp, frame);
}
}
/** Call to **manually** perform physics steps.
* By default the context uses the `physicsSteps` property to perform steps during the update loop
* If you just want to increase the accuracy of physics you can instead set the `physicsSteps` property to a higher value
* */
public updatePhysics(steps: number) {
this.internalUpdatePhysics(steps);
}
private _lastTimestamp = 0;
private _accumulatedTime = 0;
private _dispatchReadyAfterFrame = false;
// TODO: we need to skip after render callbacks if the render loop is managed externally. When changing this we also need to to update the r3f sample
private internalStep(timestamp: DOMHighResTimeStamp, frame: XRFrame | null) {
if (this.internalOnBeforeRender(timestamp, frame) === false) return;
this.internalOnRender();
this.internalOnAfterRender();
}
private internalOnBeforeRender(timestamp: DOMHighResTimeStamp, frame: XRFrame | null) {
// If we don't auto reset we get wrong stats in WebXR. AutoReset was turned off to support custom blits and count them too
this.renderer.info.autoReset = true;
const sessionStarted = frame !== null && this._xrFrame === null;
this._xrFrame = frame;
if (sessionStarted) {
this.domElement.dispatchEvent(new CustomEvent("xr-session-started", { detail: { context: this, session: this.xrSession, frame: frame } }));
}
this._currentFrameEvent = FrameEvent.Undefined;
if (this.isManagedExternally === false && this.isInXR === false && this.targetFrameRate !== undefined) {
if (this._lastTimestamp === 0) this._lastTimestamp = timestamp;
this._accumulatedTime += (timestamp - this._lastTimestamp) / 1000;
this._lastTimestamp = timestamp;
let targetFrameRate = this.targetFrameRate;
if (typeof targetFrameRate === "object") targetFrameRate = targetFrameRate.value!;
// if(debug) console.log(this._accumulatedTime, (1 / (targetFrameRate)))
if (this._accumulatedTime < (1 / (targetFrameRate + 1))) {
return false;
}
this._accumulatedTime = 0;
}
this._stats?.begin();
Context.Current = this;
if (this.onHandlePaused()) return false;
Context.Current = this;
this.time.update();
if (debugframerate)
console.log("FPS", (this.time.smoothedFps).toFixed(0));
looputils.processNewScripts(this);
looputils.updateIsActive(this.scene);
looputils.processStart(this);
invokeLifecycleFunctions(this, FrameEvent.Start);
while (this._cameraStack.length > 0 && (!this.mainCameraComponent || this.mainCameraComponent.destroyed)) {
this._cameraStack.splice(this._cameraStack.length - 1, 1);
const last = this._cameraStack[this._cameraStack.length - 1];
this.setCurrentCamera(last);
}
if (this.pre_update_oneshot_callbacks) {
for (const i in this.pre_update_oneshot_callbacks) {
this.pre_update_oneshot_callbacks[i]();
}
this.pre_update_oneshot_callbacks.length = 0;
}
if (this.pre_update_callbacks) {
for (const i in this.pre_update_callbacks) {
this.pre_update_callbacks[i]();
}
}
this._currentFrameEvent = FrameEvent.EarlyUpdate;
for (let i = 0; i < this.scripts_earlyUpdate.length; i++) {
const script = this.scripts_earlyUpdate[i];
if (!script.activeAndEnabled) continue;
if (script.earlyUpdate !== undefined) {
Context.Current = this;
script.earlyUpdate();
}
}
this.executeCoroutines(FrameEvent.EarlyUpdate);
invokeLifecycleFunctions(this, FrameEvent.EarlyUpdate);
if (this.onHandlePaused()) return false;
this._currentFrameEvent = FrameEvent.Update;
for (let i = 0; i < this.scripts_update.length; i++) {
const script = this.scripts_update[i];
if (!script.activeAndEnabled) continue;
if (script.update !== undefined) {
Context.Current = this;
script.update();
}
}
this.executeCoroutines(FrameEvent.Update);
invokeLifecycleFunctions(this, FrameEvent.Update);
if (this.o