import { Color, EquirectangularReflectionMapping, Euler, Frustum, Matrix4, OrthographicCamera, PerspectiveCamera, Ray, Vector3 } from "three"; import { Texture } 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 { Context } from "../engine/engine_setup.js"; import { RenderTexture } from "../engine/engine_texture.js"; import { getTempColor, getWorldPosition } from "../engine/engine_three_utils.js"; import type { ICamera } from "../engine/engine_types.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 enum ClearFlags { /** Don't clear the background */ None = 0, /** Clear the background with a skybox */ Skybox = 1, /** Clear the background with a solid color. The alpha channel of the color determines the transparency */ SolidColor = 2, /** Clear the background with a transparent color */ Uninitialized = 4, } 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 implements ICamera { /** * 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(): number { if (this._cam instanceof PerspectiveCamera) return this._cam.aspect; return (this.context.domWidth / this.context.domHeight); } @serializable() set aspect(value: number) { 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(): number | undefined { if (this._cam instanceof PerspectiveCamera) { return this._cam.fov; } return this._fov; } @serializable() set fieldOfView(val: number | undefined) { 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(): number { return this._nearClipPlane; } @serializable() 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(); } } private _nearClipPlane: number = 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(): number { return this._farClipPlane; } @serializable() 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(); } } private _farClipPlane: number = 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. */ @serializable() public get clearFlags(): ClearFlags { return this._clearFlags; } public set clearFlags(val: ClearFlags | "skybox" | "solidcolor") { 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. */ @serializable() public orthographic: boolean = false; /** * The size of the orthographic camera's view volume when in orthographic mode. * Larger values show more of the scene. */ @serializable() public orthographicSize: number = 5; /** * Controls the transparency level of the camera background in AR mode on supported devices. * Value from 0 (fully transparent) to 1 (fully opaque). */ @serializable() public ARBackgroundAlpha: number = 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. */ @serializable() public set cullingMask(val: number) { this._cullingMask = val; if (this._cam) { this._cam.layers.mask = val; } } public get cullingMask(): number { if (this._cam) return this._cam.layers.mask; return this._cullingMask; } private _cullingMask: number = 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 */ public set cullingLayer(val: number) { this.cullingMask = (1 << val | 0) >>> 0; } /** * Gets or sets the blurriness of the skybox background. * Values range from 0 (sharp) to 1 (maximum blur). */ @serializable() public set backgroundBlurriness(val: number | undefined) { if (val === this._backgroundBlurriness) return; if (val === undefined) this._backgroundBlurriness = undefined; else this._backgroundBlurriness = Math.min(Math.max(val, 0), 1); this.applyClearFlagsIfIsActiveCamera(); } public get backgroundBlurriness(): number | undefined { return this._backgroundBlurriness; } private _backgroundBlurriness?: number = undefined; /** * Gets or sets the intensity of the skybox background. * Values range from 0 (dark) to 10 (very bright). */ @serializable() public set backgroundIntensity(val: number | undefined) { if (val === this._backgroundIntensity) return; if (val === undefined) this._backgroundIntensity = undefined; else this._backgroundIntensity = Math.min(Math.max(val, 0), 10); this.applyClearFlagsIfIsActiveCamera(); } public get backgroundIntensity(): number | undefined { return this._backgroundIntensity; } private _backgroundIntensity?: number = undefined; /** * Gets or sets the rotation of the skybox background. * Controls the orientation of the environment map. */ @serializable(Euler) public set backgroundRotation(val: Euler | undefined) { if (val === this._backgroundRotation) return; if (val === undefined) this._backgroundRotation = undefined; else this._backgroundRotation = val; this.applyClearFlagsIfIsActiveCamera(); } public get backgroundRotation(): Euler | undefined { return this._backgroundRotation; } private _backgroundRotation?: Euler = undefined; /** * Gets or sets the intensity of the environment lighting. * Controls how strongly the environment map affects scene lighting. */ @serializable() public set environmentIntensity(val: number | undefined) { this._environmentIntensity = val; } public get environmentIntensity(): number | undefined { return this._environmentIntensity; } private _environmentIntensity?: number = undefined; /** * Gets or sets the background color of the camera when {@link ClearFlags} is set to {@link ClearFlags.SolidColor}. * The alpha component controls transparency. */ @serializable(RGBAColor) public get backgroundColor(): RGBAColor | null { return this._backgroundColor ?? null; } public set backgroundColor(val: RGBAColor | Color | null) { 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. */ @serializable(RenderTexture) public set targetTexture(rt: RenderTexture | null) { this._targetTexture = rt; } public get targetTexture(): RenderTexture | null { return this._targetTexture; } private _targetTexture: RenderTexture | null = null; private _backgroundColor?: RGBAColor; private _fov?: number; private _cam: PerspectiveCamera | OrthographicCamera | null = null; private _clearFlags: ClearFlags = ClearFlags.SolidColor; private _skybox?: CameraSkybox; /** * 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 */ public get cam(): PerspectiveCamera | OrthographicCamera { 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 */ public get threeCamera(): PerspectiveCamera | OrthographicCamera { if (this.activeAndEnabled) this.buildCamera(); return this._cam!; } private static _origin: Vector3 = new Vector3(); private static _direction: Vector3 = 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 */ public screenPointToRay(x: number, y: number, ray?: Ray): 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()); } } private _frustum?: 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 */ public getFrustum(): Frustum { 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. */ public 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 */ public getProjectionScreenMatrix(target: Matrix4, forceUpdate?: boolean) { if (forceUpdate) { this._projScreenMatrix.multiplyMatrices(this.threeCamera.projectionMatrix, this.threeCamera.matrixWorldInverse); } if (target === this._projScreenMatrix) return target; return target.copy(this._projScreenMatrix); } private readonly _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(): void { 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: PerspectiveCamera | OrthographicCamera | null = null; if (cameraAlreadyCreated) { cam = this.gameObject as any; cam?.layers.enableAll(); if (cam instanceof PerspectiveCamera) this._fov = cam.fov; } else cam = this.gameObject.children[0] as PerspectiveCamera | OrthographicCamera | null; 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?: { applySkybox: boolean }) { 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?: { applySkybox: boolean }) { 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") || this.context.domElement.getAttribute("skybox-image"); 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: 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; } } /** * Helper class for managing skybox textures for cameras. * Handles retrieving and applying skybox textures to the scene. */ class CameraSkybox { private _camera: Camera; private _skybox?: Texture; get context() { return this._camera?.context; } constructor(camera: 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) as Texture; 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: Camera) { const isFreecam = getParam("freecam"); if (isFreecam) { if (cam.context.mainCameraComponent === cam) { GameObject.getOrAddComponent(cam.gameObject, OrbitControls); } } }