@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,117 lines • 67.1 kB
JavaScript
import 'three/examples/jsm/renderers/webgl-legacy/nodes/WebGLNodes.js';
import { Color, DepthTexture, NearestFilter, NoToneMapping, Object3D, PCFSoftShadowMap, PerspectiveCamera, RGBAFormat, Scene, SRGBColorSpace, Texture, WebGLRenderer, WebGLRenderTarget } from 'three';
/** @ts-ignore (not yet in types?) */
import { BasicNodeLibrary } from "three";
import * as Stats from 'three/examples/jsm/libs/stats.module.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 { 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 { deepClone, delay, DeviceUtilities, getParam } from './engine_utils.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 = {};
export class ContextArgs {
name;
/** for debugging only */
alias;
/** the hash is used as a seed when initially loading the scene files */
hash;
/** when true the context will not check if it's visible in the viewport and always update and render */
runInBackground;
/** 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;
/** externally owned renderer */
renderer;
/** externally owned camera */
camera;
/** externally owned scene */
scene;
}
export var FrameEvent;
(function (FrameEvent) {
FrameEvent[FrameEvent["Start"] = -1] = "Start";
FrameEvent[FrameEvent["EarlyUpdate"] = 0] = "EarlyUpdate";
FrameEvent[FrameEvent["Update"] = 1] = "Update";
FrameEvent[FrameEvent["LateUpdate"] = 2] = "LateUpdate";
FrameEvent[FrameEvent["OnBeforeRender"] = 3] = "OnBeforeRender";
FrameEvent[FrameEvent["OnAfterRender"] = 4] = "OnAfterRender";
FrameEvent[FrameEvent["PrePhysicsStep"] = 9] = "PrePhysicsStep";
FrameEvent[FrameEvent["PostPhysicsStep"] = 10] = "PostPhysicsStep";
FrameEvent[FrameEvent["Undefined"] = -1] = "Undefined";
})(FrameEvent || (FrameEvent = {}));
export function registerComponent(script, 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 {
static _defaultTargetFramerate = { 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() {
return Context._defaultTargetFramerate.value;
}
/** When a new context is created this is the framerate that will be used by default */
static set DefaultTargetFrameRate(val) {
Context._defaultTargetFramerate.value = val;
}
static _defaultWebglRendererParameters = {
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() {
return Context._defaultWebglRendererParameters;
}
/** The needle engine version */
get version() {
return VERSION;
}
/** The currently active context. Only set during the update loops */
static get Current() {
return ContextRegistry.Current;
}
/** @internal this property should not be set by user code */
static set Current(context) {
ContextRegistry.Current = context;
}
static get All() {
return ContextRegistry.All;
}
/** The name of the context */
name;
/** An alias for the context */
alias;
/** 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 = 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 = false;
/** When enabled the application will run while not visible on the page */
runInBackground = 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;
/** 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
*/
physicsSteps = 1;
/** used to append to loaded assets */
hash;
/** The `<needle-engine>` web component */
domElement;
appendHTMLElement(element) {
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) {
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();
}
_resolutionScaleFactor = 1;
// domElement.clientLeft etc doesnt return absolute position
_boundingClientRectFrame = -1;
_boundingClientRect = null;
_domX;
_domY;
/** update bounding rects + domX, domY */
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() {
// 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() {
// 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() {
this.calculateBoundingClientRect();
return this._domX;
}
/** the Y position of the Needlee Engine element on the website */
get domY() {
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 = 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() { return this.renderer.xr.isPresenting ? this.renderer?.xr?.getCamera() : undefined; }
_xrFrame = null;
get arOverlayElement() {
const el = this.domElement;
if (typeof el.getAROverlayContainer === "function")
return el.getAROverlayContainer();
return this.domElement;
}
/** Current event of the update cycle */
get currentFrameEvent() {
return this._currentFrameEvent;
}
_currentFrameEvent = FrameEvent.Undefined;
scene;
renderer;
composer = null;
// all scripts
scripts = [];
scripts_pausedChanged = [];
// scripts with update event
scripts_earlyUpdate = [];
scripts_update = [];
scripts_lateUpdate = [];
scripts_onBeforeRender = [];
scripts_onAfterRender = [];
scripts_WithCorroutines = [];
scripts_immersive_vr = [];
scripts_immersive_ar = [];
coroutines = {};
/** callbacks called once after the context has been created */
post_setup_callbacks = [];
/** called every frame at the beginning of the frame (after component start events and before earlyUpdate) */
pre_update_callbacks = [];
/** called every frame before rendering (after all component events) */
pre_render_callbacks = [];
/** called every frame after rendering (after all component events) */
post_render_callbacks = [];
/** called every frame befroe update (this list is emptied every frame) */
pre_update_oneshot_callbacks = [];
new_scripts = [];
new_script_start = [];
new_scripts_pre_setup_callbacks = [];
new_scripts_post_setup_callbacks = [];
new_scripts_xr = [];
/** The main camera component of the scene - this camera is used for rendering */
mainCameraComponent = undefined;
/** The main camera of the scene - this camera is used for rendering */
get mainCamera() {
if (this._mainCamera) {
return this._mainCamera;
}
if (this.mainCameraComponent) {
const cam = this.mainCameraComponent;
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) {
this._mainCamera = cam;
}
_mainCamera = null;
_fallbackCamera = null;
application;
/** access animation mixer used by components in the scene */
animations;
/** access timings (current frame number, deltaTime, timeScale, ...) */
time;
input;
/** access physics related methods (e.g. raycasting). To access the phyiscs engine use `context.physics.engine` */
physics;
/** access networking methods (use it to send or listen to messages or join a networking backend) */
connection;
/**
* @deprecated AssetDataBase is deprecated
*/
assets;
mainLight = null;
/** @deprecated Use sceneLighting */
get rendererData() { return this.sceneLighting; }
sceneLighting;
addressables;
lightmaps;
players;
lodsManager;
menu;
get isCreated() { return this._isCreated; }
_sizeChanged = false;
_isCreated = false;
_isCreating = false;
_isVisible = false;
_stats = stats ? new Stats.default() : null;
constructor(args) {
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) {
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();
}
_intersectionObserver = null;
internalOnUpdateVisible() {
this._intersectionObserver?.disconnect();
this._intersectionObserver?.observe(this.domElement);
}
_disposeCallbacks = [];
/** 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;
/** update the renderer and canvas size */
updateSize(force = 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;
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, width, height) {
if (!camera)
return;
if (width === undefined)
width = this.domWidth;
if (height === undefined)
height = this.domHeight;
const aspectRatio = width / height;
if (camera.isPerspectiveCamera) {
const cam = camera;
const pa = cam.aspect;
cam.aspect = aspectRatio;
if (pa !== cam.aspect)
camera.updateProjectionMatrix();
}
else if (camera.isOrthographicCamera) {
const cam = camera;
// 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);
}
_originalCreationArgs;
/** @deprecated use create. This method will be removed in a future version */
async onCreate(opts) {
return this.create(opts);
}
async create(opts) {
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;
}
}
onUnhandledRejection = (event) => {
this.onError(event.reason);
};
/** Dispatches an error */
onError(error) {
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(); }
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, coroutine, evt) {
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, evt) {
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) {
for (const evt in this.coroutines) {
const rout = this.coroutines[evt];
for (let i = rout.length - 1; i >= 0; i--) {
const r = rout[i];
if (r.comp === script) {
rout.splice(i, 1);
}
}
}
}
_cameraStack = [];
setCurrentCamera(cam) {
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;
if (camera.isPerspectiveCamera)
this.updateAspect(camera);
this.mainCameraComponent?.applyClearFlagsIfIsActiveCamera();
}
removeCamera(cam) {
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);
}
}
}
_onBeforeRenderListeners = new Map();
_onAfterRenderListeners = new Map();
/** use this to subscribe to onBeforeRender events on threejs objects */
addBeforeRenderListener(target, callback) {
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, callback) {
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, callback) {
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, callback) {
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);
}
}
_createRenderCallbackWrapper(target, array) {
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);
}
};
}
_requireDepthTexture = false;
_requireColorTexture = false;
_renderTarget;
_isRendering = false;
get isRendering() { return this._isRendering; }
setRequireDepth(val) {
this._requireDepthTexture = val;
}
setRequireColor(val) {
this._requireColorTexture = val;
}
get depthTexture() {
return this._renderTarget?.depthTexture || null;
}
get opaqueColorTexture() {
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";
}
_createId = 0;
async internalOnCreate(opts) {
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;
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 = null;
foreachComponent(this.scene, comp => {
const cam = comp;
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;
}
async internalLoadInitialContent(createId, args) {
const results = new Array();
// early out if we dont have any files to load
if (args.files.length === 0)
return results;
const files = [...args.files];
const progressArg = {
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
**/
restartRenderLoop() {
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) => {
if (this.isManagedExternally)
return;
this.update(timestamp, frame);
});
return true;
}
_renderlooperrors = 0;
/** Performs a full update step including script callbacks, rendering (unless isManagedExternally is set to false) and post render callbacks */
update(timestamp, frame) {
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
* */
updatePhysics(steps) {
this.internalUpdatePhysics(steps);
}
_lastTimestamp = 0;
_accumulatedTime = 0;
_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
internalStep(timestamp, frame) {
if (this.internalOnBeforeRender(timestamp, frame) === false)
return;
this.internalOnRender();
this.internalOnAfterRender();
}
internalOnBeforeRender(timestamp, frame) {
// If we don't auto reset we get wrong stats in