@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.
701 lines • 28.7 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { EquirectangularReflectionMapping, Euler, Frustum, Matrix4, OrthographicCamera, PerspectiveCamera, Ray, Vector3 } from "three";
import { showBalloonMessage } from "../engine/debug/index.js";
import { Gizmos } from "../engine/engine_gizmos.js";
import { serializable } from "../engine/engine_serialization_decorator.js";
import { RenderTexture } from "../engine/engine_texture.js";
import { getTempColor, getWorldPosition } from "../engine/engine_three_utils.js";
import { getParam } from "../engine/engine_utils.js";
import { RGBAColor } from "../engine/js-extensions/index.js";
import { Behaviour, GameObject } from "./Component.js";
import { OrbitControls } from "./OrbitControls.js";
/**
* The ClearFlags enum is used to determine how the camera clears the background
*/
export var ClearFlags;
(function (ClearFlags) {
/** Don't clear the background */
ClearFlags[ClearFlags["None"] = 0] = "None";
/** Clear the background with a skybox */
ClearFlags[ClearFlags["Skybox"] = 1] = "Skybox";
/** Clear the background with a solid color. The alpha channel of the color determines the transparency */
ClearFlags[ClearFlags["SolidColor"] = 2] = "SolidColor";
/** Clear the background with a transparent color */
ClearFlags[ClearFlags["Uninitialized"] = 4] = "Uninitialized";
})(ClearFlags || (ClearFlags = {}));
const debug = getParam("debugcam");
const debugscreenpointtoray = getParam("debugscreenpointtoray");
/**
* Camera component that handles rendering from a specific viewpoint in the scene.
* Supports both perspective and orthographic cameras with various rendering options.
* Internally, this component uses {@link PerspectiveCamera} and {@link OrthographicCamera} three.js objects.
*
* @category Camera Controls
* @group Components
*/
export class Camera extends Behaviour {
/**
* Returns whether this component is a camera
* @returns {boolean} Always returns true
*/
get isCamera() {
return true;
}
/**
* Gets or sets the camera's aspect ratio (width divided by height).
* For perspective cameras, this directly affects the camera's projection matrix.
* When set, automatically updates the projection matrix.
*/
get aspect() {
if (this._cam instanceof PerspectiveCamera)
return this._cam.aspect;
return (this.context.domWidth / this.context.domHeight);
}
set aspect(value) {
if (this._cam instanceof PerspectiveCamera) {
if (this._cam.aspect !== value) {
this._cam.aspect = value;
this._cam.updateProjectionMatrix();
}
}
}
/**
* Gets or sets the camera's field of view in degrees for perspective cameras.
* When set, automatically updates the projection matrix.
*/
get fieldOfView() {
if (this._cam instanceof PerspectiveCamera) {
return this._cam.fov;
}
return this._fov;
}
set fieldOfView(val) {
const changed = this.fieldOfView != val;
this._fov = val;
if (changed && this._cam) {
if (this._cam instanceof PerspectiveCamera) {
if (this._fov === undefined) {
console.warn("Can not set undefined fov on PerspectiveCamera");
return;
}
this._cam.fov = this._fov;
this._cam.updateProjectionMatrix();
}
}
}
/**
* Gets or sets the camera's near clipping plane distance.
* Objects closer than this distance won't be rendered.
* When set, automatically updates the projection matrix.
*/
get nearClipPlane() { return this._nearClipPlane; }
set nearClipPlane(val) {
const changed = this._nearClipPlane != val;
this._nearClipPlane = val;
if (this._cam && (changed || this._cam.near != val)) {
this._cam.near = val;
this._cam.updateProjectionMatrix();
}
}
_nearClipPlane = 0.1;
/**
* Gets or sets the camera's far clipping plane distance.
* Objects farther than this distance won't be rendered.
* When set, automatically updates the projection matrix.
*/
get farClipPlane() { return this._farClipPlane; }
set farClipPlane(val) {
const changed = this._farClipPlane != val;
this._farClipPlane = val;
if (this._cam && (changed || this._cam.far != val)) {
this._cam.far = val;
this._cam.updateProjectionMatrix();
}
}
_farClipPlane = 1000;
/**
* Applies both the camera's near and far clipping planes and updates the projection matrix.
* This ensures rendering occurs only within the specified distance range.
*/
applyClippingPlane() {
if (this._cam) {
this._cam.near = this._nearClipPlane;
this._cam.far = this._farClipPlane;
this._cam.updateProjectionMatrix();
}
}
/**
* Gets or sets the camera's clear flags that determine how the background is rendered.
* Options include skybox, solid color, or transparent background.
*/
get clearFlags() {
return this._clearFlags;
}
set clearFlags(val) {
if (typeof val === "string") {
switch (val) {
case "skybox":
val = ClearFlags.Skybox;
break;
case "solidcolor":
val = ClearFlags.SolidColor;
break;
default:
val = ClearFlags.None;
break;
}
}
if (val === this._clearFlags)
return;
this._clearFlags = val;
this.applyClearFlagsIfIsActiveCamera();
}
/**
* Determines if the camera should use orthographic projection instead of perspective.
*/
orthographic = false;
/**
* The size of the orthographic camera's view volume when in orthographic mode.
* Larger values show more of the scene.
*/
orthographicSize = 5;
/**
* Controls the transparency level of the camera background in AR mode on supported devices.
* Value from 0 (fully transparent) to 1 (fully opaque).
*/
ARBackgroundAlpha = 0;
/**
* Gets or sets the layers mask that determines which objects this camera will render.
* Uses the {@link https://threejs.org/docs/#api/en/core/Layers.mask|three.js layers mask} convention.
*/
set cullingMask(val) {
this._cullingMask = val;
if (this._cam) {
this._cam.layers.mask = val;
}
}
get cullingMask() {
if (this._cam)
return this._cam.layers.mask;
return this._cullingMask;
}
_cullingMask = 0xffffffff;
/**
* Sets only a specific layer to be active for rendering by this camera.
* This is equivalent to calling `layers.set(val)` on the three.js camera object.
* @param val The layer index to set active
*/
set cullingLayer(val) {
this.cullingMask = (1 << val | 0) >>> 0;
}
/**
* Gets or sets the blurriness of the skybox background.
* Values range from 0 (sharp) to 1 (maximum blur).
*/
set backgroundBlurriness(val) {
if (val === this._backgroundBlurriness)
return;
if (val === undefined)
this._backgroundBlurriness = undefined;
else
this._backgroundBlurriness = Math.min(Math.max(val, 0), 1);
this.applyClearFlagsIfIsActiveCamera();
}
get backgroundBlurriness() {
return this._backgroundBlurriness;
}
_backgroundBlurriness = undefined;
/**
* Gets or sets the intensity of the skybox background.
* Values range from 0 (dark) to 10 (very bright).
*/
set backgroundIntensity(val) {
if (val === this._backgroundIntensity)
return;
if (val === undefined)
this._backgroundIntensity = undefined;
else
this._backgroundIntensity = Math.min(Math.max(val, 0), 10);
this.applyClearFlagsIfIsActiveCamera();
}
get backgroundIntensity() {
return this._backgroundIntensity;
}
_backgroundIntensity = undefined;
/**
* Gets or sets the rotation of the skybox background.
* Controls the orientation of the environment map.
*/
set backgroundRotation(val) {
if (val === this._backgroundRotation)
return;
if (val === undefined)
this._backgroundRotation = undefined;
else
this._backgroundRotation = val;
this.applyClearFlagsIfIsActiveCamera();
}
get backgroundRotation() {
return this._backgroundRotation;
}
_backgroundRotation = undefined;
/**
* Gets or sets the intensity of the environment lighting.
* Controls how strongly the environment map affects scene lighting.
*/
set environmentIntensity(val) {
this._environmentIntensity = val;
}
get environmentIntensity() {
return this._environmentIntensity;
}
_environmentIntensity = undefined;
/**
* Gets or sets the background color of the camera when {@link ClearFlags} is set to {@link ClearFlags.SolidColor}.
* The alpha component controls transparency.
*/
get backgroundColor() {
return this._backgroundColor ?? null;
}
set backgroundColor(val) {
if (!val)
return;
if (!this._backgroundColor) {
this._backgroundColor = new RGBAColor(1, 1, 1, 1);
}
this._backgroundColor.copy(val);
// set background color to solid if provided color doesnt have any alpha channel
if ((!("alpha" in val) || val.alpha === undefined)) {
this._backgroundColor.alpha = 1;
}
this.applyClearFlagsIfIsActiveCamera();
}
/**
* Gets or sets the texture that the camera should render to instead of the screen.
* Useful for creating effects like mirrors, portals or custom post processing.
*/
set targetTexture(rt) {
this._targetTexture = rt;
}
get targetTexture() {
return this._targetTexture;
}
_targetTexture = null;
_backgroundColor;
_fov;
_cam = null;
_clearFlags = ClearFlags.SolidColor;
_skybox;
/**
* Gets the three.js camera object. Creates one if it doesn't exist yet.
* @returns {PerspectiveCamera | OrthographicCamera} The three.js camera object
* @deprecated Use {@link threeCamera} instead
*/
get cam() {
return this.threeCamera;
}
/**
* Gets the three.js camera object. Creates one if it doesn't exist yet.
* @returns {PerspectiveCamera | OrthographicCamera} The three.js camera object
*/
get threeCamera() {
if (this.activeAndEnabled)
this.buildCamera();
return this._cam;
}
static _origin = new Vector3();
static _direction = new Vector3();
/**
* Converts screen coordinates to a ray in world space.
* Useful for implementing picking or raycasting from screen to world.
*
* @param x The x screen coordinate
* @param y The y screen coordinate
* @param ray Optional ray object to reuse instead of creating a new one
* @returns {Ray} A ray originating from the camera position pointing through the screen point
*/
screenPointToRay(x, y, ray) {
const cam = this.threeCamera;
const origin = Camera._origin;
origin.set(x, y, -1);
this.context.input.convertScreenspaceToRaycastSpace(origin);
if (debugscreenpointtoray)
console.log("screenPointToRay", x.toFixed(2), y.toFixed(2), "now:", origin.x.toFixed(2), origin.y.toFixed(2), "isInXR:" + this.context.isInXR);
origin.z = -1;
origin.unproject(cam);
const dir = Camera._direction.set(origin.x, origin.y, origin.z);
const camPosition = getWorldPosition(cam);
dir.sub(camPosition);
dir.normalize();
if (ray) {
ray.set(camPosition, dir);
return ray;
}
else {
return new Ray(camPosition.clone(), dir.clone());
}
}
_frustum;
/**
* Gets the camera's view frustum for culling and visibility checks.
* Creates the frustum if it doesn't exist and returns it.
*
* @returns {Frustum} The camera's view frustum
*/
getFrustum() {
if (!this._frustum) {
this._frustum = new Frustum();
this.updateFrustum();
}
return this._frustum;
}
/**
* Forces an update of the camera's frustum.
* This is automatically called every frame in onBeforeRender.
*/
updateFrustum() {
if (!this._frustum)
this._frustum = new Frustum();
this._frustum.setFromProjectionMatrix(this.getProjectionScreenMatrix(this._projScreenMatrix, true), this.context.renderer.coordinateSystem);
}
/**
* Gets this camera's projection-screen matrix.
*
* @param target Matrix4 object to store the result in
* @param forceUpdate Whether to force recalculation of the matrix
* @returns {Matrix4} The requested projection screen matrix
*/
getProjectionScreenMatrix(target, forceUpdate) {
if (forceUpdate) {
this._projScreenMatrix.multiplyMatrices(this.threeCamera.projectionMatrix, this.threeCamera.matrixWorldInverse);
}
if (target === this._projScreenMatrix)
return target;
return target.copy(this._projScreenMatrix);
}
_projScreenMatrix = new Matrix4();
/** @internal */
awake() {
if (debugscreenpointtoray) {
window.addEventListener("pointerdown", evt => {
const px = evt.clientX;
const py = evt.clientY;
console.log("touch", px.toFixed(2), py.toFixed(2));
const ray = this.screenPointToRay(px, py);
const randomHex = "#" + Math.floor(Math.random() * 16777215).toString(16);
Gizmos.DrawRay(ray.origin, ray.direction, randomHex, 10);
});
}
}
/** @internal */
onEnable() {
if (debug)
console.log(`Camera enabled: \"${this.name}\". ClearFlags=${ClearFlags[this._clearFlags]}`, this);
this.buildCamera();
if (this.tag == "MainCamera" || !this.context.mainCameraComponent) {
this.context.setCurrentCamera(this);
handleFreeCam(this);
}
this.applyClearFlagsIfIsActiveCamera({ applySkybox: true });
}
/** @internal */
onDisable() {
this.context.removeCamera(this);
}
/** @internal */
onBeforeRender() {
if (this._cam) {
if (this._frustum) {
this.updateFrustum();
}
// because the background color may be animated!
if (this._clearFlags === ClearFlags.SolidColor)
this.applyClearFlagsIfIsActiveCamera();
if (this._targetTexture) {
if (this.context.isManagedExternally) {
// TODO: rendering with r3f renderer does throw an shader error for some reason?
if (!this["_warnedAboutExternalRenderer"]) {
this["_warnedAboutExternalRenderer"] = true;
console.warn("Rendering with external renderer is not supported yet. This may not work or throw errors. Please remove the the target texture from your camera: " + this.name, this.targetTexture);
}
}
// TODO: optimize to not render twice if this is already the main camera. In that case we just want to blit
const composer = this.context.composer;
const useNormalRenderer = true; // this.context.isInXR || !composer;
const renderer = useNormalRenderer ? this.context.renderer : composer;
if (renderer) {
// TODO: we should do this in onBeforeRender for the main camera only
const mainCam = this.context.mainCameraComponent;
this.applyClearFlags();
this._targetTexture.render(this.context.scene, this._cam, renderer);
mainCam?.applyClearFlags();
}
}
}
}
/**
* Creates a three.js camera object if it doesn't exist yet and sets its properties.
* This is called internally when accessing the {@link threeCamera} property.
*/
buildCamera() {
if (this._cam)
return;
const cameraAlreadyCreated = this.gameObject["isCamera"];
// TODO: when exporting from blender we already have a camera in the children
let cam = null;
if (cameraAlreadyCreated) {
cam = this.gameObject;
cam?.layers.enableAll();
if (cam instanceof PerspectiveCamera)
this._fov = cam.fov;
}
else
cam = this.gameObject.children[0];
if (cam && cam.isCamera) {
if (cam instanceof PerspectiveCamera) {
if (this._fov)
cam.fov = this._fov;
cam.near = this._nearClipPlane;
cam.far = this._farClipPlane;
cam.updateProjectionMatrix();
}
}
else if (!this.orthographic) {
cam = new PerspectiveCamera(this.fieldOfView, window.innerWidth / window.innerHeight, this._nearClipPlane, this._farClipPlane);
if (this.fieldOfView)
cam.fov = this.fieldOfView;
this.gameObject.add(cam);
}
else {
const factor = this.orthographicSize * 100;
cam = new OrthographicCamera(window.innerWidth / -factor, window.innerWidth / factor, window.innerHeight / factor, window.innerHeight / -factor, this._nearClipPlane, this._farClipPlane);
this.gameObject.add(cam);
}
this._cam = cam;
this._cam.layers.mask = this._cullingMask;
if (this.tag == "MainCamera") {
this.context.setCurrentCamera(this);
}
}
/**
* Applies clear flags if this is the active main camera.
* @param opts Options for applying clear flags
*/
applyClearFlagsIfIsActiveCamera(opts) {
if (this.context.mainCameraComponent === this) {
this.applyClearFlags(opts);
}
}
/**
* Applies this camera's clear flags and related settings to the renderer.
* This controls how the background is rendered (skybox, solid color, transparent).
* @param opts Options for applying clear flags
*/
applyClearFlags(opts) {
if (!this._cam) {
if (debug)
console.log("Camera does not exist (apply clear flags)");
return;
}
// restore previous fov (e.g. when user was in VR or AR and the camera's fov has changed)
this.fieldOfView = this._fov;
if (debug) {
const msg = `[Camera] Apply ClearFlags: ${ClearFlags[this._clearFlags]} - \"${this.name}\"`;
console.debug(msg);
}
const hasBackgroundImageOrColorAttribute = this.context.domElement.getAttribute("background-image") || this.context.domElement.getAttribute("background-color");
switch (this._clearFlags) {
case ClearFlags.None:
return;
case ClearFlags.Skybox:
if (Camera.backgroundShouldBeTransparent(this.context)) {
if (!this.ARBackgroundAlpha || this.ARBackgroundAlpha < 0.001) {
this.context.scene.background = null;
this.context.renderer.setClearColor(0x000000, 0);
return;
}
}
// apply the skybox only if it is not already set or if it's the first time (e.g. if the _skybox is not set yet)
if (!this.scene.background || !this._skybox || opts?.applySkybox === true)
this.applySceneSkybox();
// set background blurriness and intensity
if (this._backgroundBlurriness !== undefined && !this.context.domElement.getAttribute("background-blurriness"))
this.context.scene.backgroundBlurriness = this._backgroundBlurriness;
else if (debug)
console.warn(`Camera \"${this.name}\" has no background blurriness`);
if (this._backgroundIntensity !== undefined && !this.context.domElement.getAttribute("background-intensity"))
this.context.scene.backgroundIntensity = this._backgroundIntensity;
if (this._backgroundRotation !== undefined && !this.context.domElement.getAttribute("background-rotation"))
this.context.scene.backgroundRotation = this._backgroundRotation;
else if (debug)
console.warn(`Camera \"${this.name}\" has no background intensity`);
break;
case ClearFlags.SolidColor:
if (this._backgroundColor && !hasBackgroundImageOrColorAttribute) {
let alpha = this._backgroundColor.alpha;
// when in WebXR use ar background alpha override or set to 0
if (Camera.backgroundShouldBeTransparent(this.context)) {
alpha = this.ARBackgroundAlpha ?? 0;
}
this.context.scene.background = null;
// In WebXR VR the background colorspace is wrong
if (this.context.xr?.isVR) {
this.context.renderer.setClearColor(getTempColor(this._backgroundColor).convertLinearToSRGB());
}
else {
this.context.renderer.setClearColor(this._backgroundColor, alpha);
}
}
else if (!this._backgroundColor) {
if (debug)
console.warn(`[Camera] has no background color \"${this.name}\" `);
}
break;
case ClearFlags.Uninitialized:
if (!hasBackgroundImageOrColorAttribute) {
this.context.scene.background = null;
this.context.renderer.setClearColor(0x000000, 0);
}
break;
}
}
/**
* Applies the skybox texture to the scene background.
*/
applySceneSkybox() {
if (!this._skybox)
this._skybox = new CameraSkybox(this);
this._skybox.apply();
}
/**
* Determines if the background should be transparent when in passthrough AR mode.
*
* @param context The current rendering context
* @returns {boolean} True when in XR on a pass through device where the background should be invisible
*/
static backgroundShouldBeTransparent(context) {
const session = context.renderer.xr?.getSession();
if (!session)
return false;
if (typeof session["_transparent"] === "boolean") {
return session["_transparent"];
}
const environmentBlendMode = session.environmentBlendMode;
if (debug)
showBalloonMessage("Environment blend mode: " + environmentBlendMode + " on " + navigator.userAgent);
let transparent = environmentBlendMode === 'additive' || environmentBlendMode === 'alpha-blend';
if (context.isInAR) {
if (environmentBlendMode === "opaque") {
// workaround for Quest 2 returning opaque when it should be alpha-blend
// check user agent if this is the Quest browser and return true if so
if (navigator.userAgent?.includes("OculusBrowser")) {
transparent = true;
}
// Mozilla WebXR Viewer
else if (navigator.userAgent?.includes("Mozilla") && navigator.userAgent?.includes("Mobile WebXRViewer/v2")) {
transparent = true;
}
}
}
session["_transparent"] = transparent;
return transparent;
}
}
__decorate([
serializable()
], Camera.prototype, "aspect", null);
__decorate([
serializable()
], Camera.prototype, "fieldOfView", null);
__decorate([
serializable()
], Camera.prototype, "nearClipPlane", null);
__decorate([
serializable()
], Camera.prototype, "farClipPlane", null);
__decorate([
serializable()
], Camera.prototype, "clearFlags", null);
__decorate([
serializable()
], Camera.prototype, "orthographic", void 0);
__decorate([
serializable()
], Camera.prototype, "orthographicSize", void 0);
__decorate([
serializable()
], Camera.prototype, "ARBackgroundAlpha", void 0);
__decorate([
serializable()
], Camera.prototype, "cullingMask", null);
__decorate([
serializable()
], Camera.prototype, "backgroundBlurriness", null);
__decorate([
serializable()
], Camera.prototype, "backgroundIntensity", null);
__decorate([
serializable(Euler)
], Camera.prototype, "backgroundRotation", null);
__decorate([
serializable()
], Camera.prototype, "environmentIntensity", null);
__decorate([
serializable(RGBAColor)
], Camera.prototype, "backgroundColor", null);
__decorate([
serializable(RenderTexture)
], Camera.prototype, "targetTexture", null);
/**
* Helper class for managing skybox textures for cameras.
* Handles retrieving and applying skybox textures to the scene.
*/
class CameraSkybox {
_camera;
_skybox;
get context() { return this._camera?.context; }
constructor(camera) {
this._camera = camera;
}
/**
* Applies the skybox texture to the scene background.
* Retrieves the texture based on the camera's source ID.
*/
apply() {
this._skybox = this.context.lightmaps.tryGetSkybox(this._camera.sourceId);
if (!this._skybox) {
if (!this["_did_log_failed_to_find_skybox"]) {
this["_did_log_failed_to_find_skybox"] = true;
console.warn(`Camera \"${this._camera.name}\" has no skybox texture. ${this._camera.sourceId}`);
}
}
else if (this.context.scene.background !== this._skybox) {
const hasBackgroundAttribute = this.context.domElement.getAttribute("background-image") || this.context.domElement.getAttribute("background-color") || this.context.domElement.getAttribute("skybox-image");
if (debug)
console.debug(`[Camera] Apply Skybox ${this._skybox?.name} ${hasBackgroundAttribute} - \"${this._camera.name}\"`);
if (!hasBackgroundAttribute?.length) {
this._skybox.mapping = EquirectangularReflectionMapping;
this.context.scene.background = this._skybox;
}
}
}
}
/**
* Adds orbit controls to the camera if the freecam URL parameter is enabled.
*
* @param cam The camera to potentially add orbit controls to
*/
function handleFreeCam(cam) {
const isFreecam = getParam("freecam");
if (isFreecam) {
if (cam.context.mainCameraComponent === cam) {
GameObject.getOrAddComponent(cam.gameObject, OrbitControls);
}
}
}
//# sourceMappingURL=Camera.js.map