@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.
929 lines (803 loc) • 34.2 kB
text/typescript
import { AnimationAction, Box3, Box3Helper, Camera, Color, DepthTexture, Euler, GridHelper, Layers, Material, Mesh, MeshStandardMaterial, Object3D, OrthographicCamera, PerspectiveCamera, PlaneGeometry, Quaternion, Scene, ShadowMaterial, Texture, Uniform, Vector2Like, Vector3, WebGLRenderTarget } from "three";
import { ShaderMaterial, WebGLRenderer } from "three";
import { GroundedSkybox } from "three/examples/jsm/objects/GroundedSkybox.js";
import { useForAutoFit } from "./engine_camera.js";
import { Mathf } from "./engine_math.js"
import { Vec3 } from "./engine_types.js";
import { CircularBuffer } from "./engine_utils.js";
/**
* Slerp between two vectors
*/
export function slerp(vec: Vector3, end: Vector3, t: number) {
const len1 = vec.length();
const len2 = end.length();
const targetLen = Mathf.lerp(len1, len2, t);
return vec.lerp(end, t).normalize().multiplyScalar(targetLen);
}
const _tempQuat = new Quaternion();
const flipYQuat: Quaternion = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
export function lookAtInverse(obj: Object3D, target: Vector3) {
obj.lookAt(target);
obj.quaternion.multiply(flipYQuat);
}
/** Better lookAt
* @param object the object that the lookAt should be applied to
* @param target the target to look at
* @param keepUpDirection if true the up direction will be kept
* @param copyTargetRotation if true the target rotation will be copied so the rotation is not skewed
*/
export function lookAtObject(object: Object3D, target: Object3D, keepUpDirection: boolean = true, copyTargetRotation: boolean = false) {
if (object === target) return;
_tempQuat.copy(object.quaternion);
const lookTarget = getWorldPosition(target);
const lookFrom = getWorldPosition(object);
if (copyTargetRotation) {
setWorldQuaternion(object, getWorldQuaternion(target));
// look at forward again so we don't get any roll
if (keepUpDirection) {
const ypos = lookFrom.y;
const forwardPoint = lookFrom.sub(getWorldDirection(object));
forwardPoint.y = ypos;
object.lookAt(forwardPoint);
object.quaternion.multiply(flipYQuat);
}
// sanitize – three.js sometimes returns NaN values when parents are non-uniformly scaled
if (Number.isNaN(object.quaternion.x)) {
object.quaternion.copy(_tempQuat);
}
return;
}
if (keepUpDirection) {
lookTarget.y = lookFrom.y;
}
object.lookAt(lookTarget);
// sanitize – three.js sometimes returns NaN values when parents are non-uniformly scaled
if (Number.isNaN(object.quaternion.x)) {
object.quaternion.copy(_tempQuat);
}
}
/**
* Look at a 2D point in screen space
* @param object the object to look at the point
* @param target the target point in 2D screen space XY e.g. from a mouse event
* @param camera the camera to use for the lookAt
* @param factor the factor to multiply the distance from the camera to the object. Default is 1
* @returns the target point in world space
*
* @example Needle Engine Component
* ```ts
* export class MyLookAtComponent extends Behaviour {
* update() {
* lookAtScreenPoint(this.gameObject, this.context.input.mousePosition, this.context.mainCamera);
* }
* }
* ```
*
* @example Look at from browser mouse move event
* ```ts
* window.addEventListener("mousemove", (e) => {
* lookAtScreenPoint(object, new Vector2(e.clientX, e.clientY), camera);
* });
* ```
*/
export function lookAtScreenPoint(object: Object3D, target: Vector2Like, camera: Camera, factor: number = 1): Vector3 | null {
if (camera) {
const pos = getTempVector(0, 0, 0);
const ndcx = (target.x / window.innerWidth) * 2 - 1;
const ndcy = -(target.y / window.innerHeight) * 2 + 1;
pos.set(
ndcx,
ndcy,
0
);
pos.unproject(camera);
// get distance from object to camera
const camPos = camera.worldPosition;
const dist = object.worldPosition.distanceTo(camPos);
// Create direction from camera through cursor point
const dir = pos.sub(camPos);
dir.multiplyScalar(factor * 3.6 * dist);
const targetPoint = camera.worldPosition.add(dir);
object.lookAt(targetPoint);
return targetPoint;
}
return null;
}
const _tempVecs = new CircularBuffer(() => new Vector3(), 100);
/** Gets a temporary vector. If a vector is passed in it will be copied to the temporary vector
* Temporary vectors are cached and reused internally. Don't store them!
* @param vec3 the vector to copy or the x value
* @param y the y value
* @param z the z value
* @returns a temporary vector
*
* @example
* ``` javascript
* const vec = getTempVector(1, 2, 3);
* const vec2 = getTempVector(vec);
* const vec3 = getTempVector(new Vector3(1, 2, 3));
* const vec4 = getTempVector(new DOMPointReadOnly(1, 2, 3));
* const vec5 = getTempVector();
* ```
*/
export function getTempVector(): Vector3;
export function getTempVector(vec3: Vector3): Vector3;
export function getTempVector(vec3: [number, number, number]): Vector3;
export function getTempVector(vec3: Vec3): Vector3;
export function getTempVector(dom: DOMPointReadOnly): Vector3;
export function getTempVector(x: number): Vector3;
export function getTempVector(x: number, y: number, z: number): Vector3;
export function getTempVector(vecOrX?: Vec3 | Vector3 | [number, number, number] | DOMPointReadOnly | number, y?: number, z?: number): Vector3 {
const vec = _tempVecs.get();
vec.set(0, 0, 0); // initialize with default values
if (vecOrX instanceof Vector3) vec.copy(vecOrX);
else if (Array.isArray(vecOrX)) vec.set(vecOrX[0], vecOrX[1], vecOrX[2]);
else if (vecOrX instanceof DOMPointReadOnly) vec.set(vecOrX.x, vecOrX.y, vecOrX.z);
else {
if (typeof vecOrX === "number") {
vec.x = vecOrX;
vec.y = y !== undefined ? y : vec.x;
vec.z = z !== undefined ? z : vec.x;
}
else if (typeof vecOrX === "object") {
vec.x = vecOrX.x;
vec.y = vecOrX.y;
vec.z = vecOrX.z;
}
}
return vec;
}
const _tempColors = new CircularBuffer(() => new Color(), 30);
export function getTempColor(color?: Color) {
const col = _tempColors.get();
if (color) col.copy(color);
else {
col.set(0, 0, 0);
}
return col;
}
const _tempQuats = new CircularBuffer(() => new Quaternion(), 100);
/**
* Gets a temporary quaternion. If a quaternion is passed in it will be copied to the temporary quaternion
* Temporary quaternions are cached and reused internally. Don't store them!
* @param value the quaternion to copy
* @returns a temporary quaternion
*/
export function getTempQuaternion(value?: Quaternion | DOMPointReadOnly) {
const val = _tempQuats.get();
val.identity();
if (value instanceof Quaternion) val.copy(value);
else if (value instanceof DOMPointReadOnly) val.set(value.x, value.y, value.z, value.w);
return val;
}
const _worldPositions = new CircularBuffer(() => new Vector3(), 100);
const _lastMatrixWorldUpdateKey = Symbol("lastMatrixWorldUpdateKey");
/**
* Get the world position of an object
* @param obj the object to get the world position from
* @param vec a vector to store the result in. If not passed in a temporary vector will be used
* @param updateParents if true the parents will be updated before getting the world position
* @returns the world position
*/
export function getWorldPosition(obj: Object3D, vec: Vector3 | null = null, updateParents: boolean = true): Vector3 {
const wp = vec ?? _worldPositions.get();
if (!obj) return wp.set(0, 0, 0);
if (!obj.parent) return wp.copy(obj.position);
if (updateParents)
obj.updateWorldMatrix(true, false);
if (obj.matrixWorldNeedsUpdate && obj[_lastMatrixWorldUpdateKey] !== Date.now()) {
obj[_lastMatrixWorldUpdateKey] = Date.now();
obj.updateMatrixWorld();
}
wp.setFromMatrixPosition(obj.matrixWorld);
return wp;
}
/**
* Set the world position of an object
* @param obj the object to set the world position of
* @param val the world position to set
*/
export function setWorldPosition(obj: Object3D, val: Vector3): Object3D {
if (!obj) return obj;
const wp = _worldPositions.get();
if (val !== wp)
wp.copy(val);
const obj2 = obj?.parent ?? obj;
obj2.worldToLocal(wp);
obj.position.set(wp.x, wp.y, wp.z);
return obj;
}
/**
* Set the world position of an object
* @param obj the object to set the world position of
* @param x the x position
* @param y the y position
* @param z the z position
*/
export function setWorldPositionXYZ(obj: Object3D, x: number, y: number, z: number): Object3D {
const wp = _worldPositions.get();
wp.set(x, y, z);
setWorldPosition(obj, wp);
return obj;
}
const _worldQuaternions = new CircularBuffer(() => new Quaternion(), 100);
const _worldQuaternionBuffer: Quaternion = new Quaternion();
const _tempQuaternionBuffer2: Quaternion = new Quaternion();
export function getWorldQuaternion(obj: Object3D, target: Quaternion | null = null): Quaternion {
if (!obj) return _worldQuaternions.get().identity();
const quat = target ?? _worldQuaternions.get();
if (!obj.parent) return quat.copy(obj.quaternion);
obj.getWorldQuaternion(quat);
return quat;
}
export function setWorldQuaternion(obj: Object3D, val: Quaternion) {
if (!obj) return;
if (val !== _worldQuaternionBuffer)
_worldQuaternionBuffer.copy(val);
const tempVec = _worldQuaternionBuffer;
const parent = obj?.parent;
parent?.getWorldQuaternion(_tempQuaternionBuffer2);
_tempQuaternionBuffer2.invert();
const q = _tempQuaternionBuffer2.multiply(tempVec);
// console.log(tempVec);
obj.quaternion.set(q.x, q.y, q.z, q.w);
// console.error("quaternion world to local is not yet implemented");
}
export function setWorldQuaternionXYZW(obj: Object3D, x: number, y: number, z: number, w: number) {
_worldQuaternionBuffer.set(x, y, z, w);
setWorldQuaternion(obj, _worldQuaternionBuffer);
}
const _worldScaleBuffer = new CircularBuffer(() => new Vector3(), 100);
const _worldScale: Vector3 = new Vector3();
export function getWorldScale(obj: Object3D, vec: Vector3 | null = null): Vector3 {
if (!vec)
vec = _worldScaleBuffer.get();
if (!obj) return vec.set(0, 0, 0);
if (!obj.parent) return vec.copy(obj.scale);
obj.getWorldScale(vec);
return vec;
}
export function setWorldScale(obj: Object3D, vec: Vector3) {
if (!obj) return;
if (!obj.parent) {
obj.scale.copy(vec);
return;
}
const tempVec = _worldScale;
const obj2 = obj.parent;
obj2.getWorldScale(tempVec);
obj.scale.copy(vec);
obj.scale.divide(tempVec);
}
const _forward = new Vector3();
const _forwardQuat = new Quaternion();
export function forward(obj: Object3D): Vector3 {
getWorldQuaternion(obj, _forwardQuat);
return _forward.set(0, 0, 1).applyQuaternion(_forwardQuat);
}
const _worldDirectionBuffer = new CircularBuffer(() => new Vector3(), 100);
const _worldDirectionQuat = new Quaternion();
/** Get the world direction. Returns world forward if nothing is passed in.
* Pass in a relative direction to get it converted to world space (e.g. dir = new Vector3(0, 1, 1))
* The returned vector will not be normalized
*/
export function getWorldDirection(obj: Object3D, dir?: Vector3) {
// If no direction is passed in set the direction to the forward vector
if (!dir) dir = _worldDirectionBuffer.get().set(0, 0, 1);
getWorldQuaternion(obj, _worldDirectionQuat);
return dir.applyQuaternion(_worldDirectionQuat);
}
const _worldEulerBuffer: Euler = new Euler();
const _worldEuler: Euler = new Euler();
const _worldRotation: Vector3 = new Vector3();
// world euler (in radians)
export function getWorldEuler(obj: Object3D): Euler {
const quat = _worldQuaternions.get();
obj.getWorldQuaternion(quat);
_worldEuler.setFromQuaternion(quat);
return _worldEuler;
}
// world euler (in radians)
export function setWorldEuler(obj: Object3D, val: Euler) {
const quat = _worldQuaternions.get();
setWorldQuaternion(obj, quat.setFromEuler(val));;
}
// returns rotation in degrees
export function getWorldRotation(obj: Object3D): Vector3 {
const rot = getWorldEuler(obj);
const wr = _worldRotation;
wr.set(rot.x, rot.y, rot.z);
wr.x = Mathf.toDegrees(wr.x);
wr.y = Mathf.toDegrees(wr.y);
wr.z = Mathf.toDegrees(wr.z);
return wr;
}
export function setWorldRotation(obj: Object3D, val: Vector3) {
setWorldRotationXYZ(obj, val.x, val.y, val.z, true);
}
export function setWorldRotationXYZ(obj: Object3D, x: number, y: number, z: number, degrees: boolean = true) {
if (degrees) {
x = Mathf.toRadians(x);
y = Mathf.toRadians(y);
z = Mathf.toRadians(z);
}
_worldEulerBuffer.set(x, y, z);
_worldQuaternionBuffer.setFromEuler(_worldEulerBuffer);
setWorldQuaternion(obj, _worldQuaternionBuffer);
}
// from https://github.com/mrdoob/js/pull/10995#issuecomment-287614722
export function logHierarchy(root: Object3D | null | undefined, collapsible: boolean = true) {
if (!root) return;
if (collapsible) {
(function printGraph(obj: Object3D) {
console.groupCollapsed((obj.name ? obj.name : '(no name : ' + obj.type + ')') + ' %o', obj);
obj.children.forEach(printGraph);
console.groupEnd();
}(root));
} else {
root.traverse(function (obj: Object3D) {
var s = '|___';
var obj2 = obj;
while (obj2.parent !== null) {
s = '\t' + s;
obj2 = obj2.parent;
}
console.log(s + obj.name + ' <' + obj.type + '>');
});
};
}
export function getParentHierarchyPath(obj: Object3D): string {
let path = obj?.name || "";
if (!obj) return path;
let parent = obj.parent;
while (parent) {
path = parent.name + "/" + path;
parent = parent.parent;
}
return path;
}
export function isAnimationAction(obj: object) {
if (obj) {
// this doesnt work :(
// return obj instanceof AnimationAction;
// instead we do this:
const act = obj as AnimationAction;
return act.blendMode !== undefined && act.clampWhenFinished !== undefined && act.enabled !== undefined && act.fadeIn !== undefined && act.getClip !== undefined;
}
return false;
}
class BlitMaterial extends ShaderMaterial {
static vertex = `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = vec4(position.xy, 0., 1.0);
}`;
constructor() {
super({
vertexShader: BlitMaterial.vertex,
uniforms: {
map: new Uniform(null),
flipY: new Uniform(true),
writeDepth: new Uniform(false),
depthTexture: new Uniform(null)
},
fragmentShader: `
uniform sampler2D map;
uniform bool flipY;
uniform bool writeDepth;
uniform sampler2D depthTexture;
varying vec2 vUv;
void main(){
vec2 uv = vUv;
if (flipY) uv.y = 1.0 - uv.y;
gl_FragColor = texture2D(map, uv);
if (writeDepth) {
float depth = texture2D(depthTexture, uv).r;
gl_FragDepth = depth;
// float linearDepth = (depth - 0.99) * 100.0; // Enhance near 1.0 values
// gl_FragColor = vec4(linearDepth, linearDepth, linearDepth, 1.0);
}
}`
});
}
reset() {
this.uniforms.map.value = null;
this.uniforms.flipY.value = true;
this.uniforms.writeDepth.value = false;
this.uniforms.depthTexture.value = null;
this.needsUpdate = true;
this.uniformsNeedUpdate = true;
}
}
/**
* Utility class to perform various graphics operations like copying textures to canvas
*/
export class Graphics {
private static readonly planeGeometry = new PlaneGeometry(2, 2, 1, 1);
private static readonly renderer: WebGLRenderer = new WebGLRenderer({ antialias: false, alpha: true });
private static readonly perspectiveCam = new PerspectiveCamera();
private static readonly orthographicCam = new OrthographicCamera();
private static readonly scene = new Scene();
private static readonly blitMaterial: BlitMaterial = new BlitMaterial();
private static readonly mesh: Mesh = new Mesh(Graphics.planeGeometry, Graphics.blitMaterial);
/**
* Copy a texture to a new texture
* @param texture the texture to copy
* @param blitMaterial the material to use for copying (optional)
* @returns the newly created, copied texture
*/
static copyTexture(texture: Texture, blitMaterial?: ShaderMaterial): Texture {
// ensure that a blit material exists
if (!blitMaterial) {
blitMaterial = this.blitMaterial;
};
this.blitMaterial.reset();
const material = blitMaterial || this.blitMaterial;
// TODO: reset the uniforms...
material.uniforms.map.value = texture;
material.needsUpdate = true;
material.uniformsNeedUpdate = true;
// ensure that the blit material has the correct vertex shader
const origVertexShader = material.vertexShader;
material.vertexShader = BlitMaterial.vertex;
const mesh = this.mesh;
mesh.material = material;
mesh.frustumCulled = false;
this.scene.children.length = 0;
this.scene.add(mesh);
this.renderer.setSize(texture.image.width, texture.image.height);
this.renderer.clear();
this.renderer.render(this.scene, this.perspectiveCam);
const tex = new Texture(this.renderer.domElement);
tex.name = "Copy";
tex.needsUpdate = true; // < important!
// reset vertex shader
material.vertexShader = origVertexShader;
return tex;
}
static blit(src: Texture, target: WebGLRenderTarget, options?: {
renderer?: WebGLRenderer,
blitMaterial?: ShaderMaterial,
flipY?: boolean,
depthTexture?: DepthTexture | null,
depthTest?: boolean,
depthWrite?: boolean,
}) {
const {
renderer = this.renderer,
blitMaterial = this.blitMaterial,
flipY = false,
depthTexture = null,
depthTest = true,
depthWrite = true,
} = options || {};
this.blitMaterial.reset();
if (blitMaterial.uniforms.map) blitMaterial.uniforms.map.value = src;
if (blitMaterial.uniforms.flipY) blitMaterial.uniforms.flipY.value = flipY;
if (depthTexture) {
blitMaterial.uniforms.writeDepth = new Uniform(true);
blitMaterial.uniforms.depthTexture.value = depthTexture;
}
else {
blitMaterial.uniforms.writeDepth = new Uniform(false);
blitMaterial.uniforms.depthTexture.value = null;
}
blitMaterial.needsUpdate = true;
blitMaterial.uniformsNeedUpdate = true;
const mesh = this.mesh;
mesh.material = blitMaterial;
mesh.frustumCulled = false;
this.scene.children.length = 0;
this.scene.add(mesh);
// Save state
const renderTarget = renderer.getRenderTarget();
const gl = renderer.getContext();
const depthTestEnabled = gl.getParameter(gl.DEPTH_TEST);
const depthWriteMask = gl.getParameter(gl.DEPTH_WRITEMASK);
const depthFunc = gl.getParameter(gl.DEPTH_FUNC);
// Set state
if (depthTest) renderer.getContext().enable(renderer.getContext().DEPTH_TEST);
else renderer.getContext().disable(renderer.getContext().DEPTH_TEST);
renderer.state.buffers.depth.setMask(depthWrite);
renderer.setClearColor(new Color(0, 0, 0), 0);
// renderer.setSize(target.width, target.height);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setRenderTarget(target);
renderer.clear();
renderer.render(this.scene, this.perspectiveCam);
// Restore state
renderer.setRenderTarget(renderTarget); // reset render target
const depthBuffer = renderer.state.buffers.depth;
depthBuffer.setTest(depthTestEnabled);
depthBuffer.setMask(depthWriteMask);
depthBuffer.setFunc(depthFunc);
}
/**
* Copy a texture to a HTMLCanvasElement
* @param texture the texture convert
* @param force if true the texture will be copied to a new texture before converting
* @returns the HTMLCanvasElement with the texture or null if the texture could not be copied
*/
static textureToCanvas(texture: Texture, force: boolean = false): HTMLCanvasElement | null {
if (!texture) {
return null;
}
if (force === true || texture["isCompressedTexture"] === true) {
texture = copyTexture(texture);
}
const image = texture.image;
if (isImageBitmap(image)) {
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
if (!context) {
console.error("Failed getting canvas 2d context");
return null;
}
context.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height);
return canvas;
}
return null;
}
}
/**@obsolete use Graphics.copyTexture */
export function copyTexture(texture: Texture): Texture {
return Graphics.copyTexture(texture);
}
/**@obsolete use Graphics.textureToCanvas */
export function textureToCanvas(texture: Texture, force: boolean = false): HTMLCanvasElement | null {
return Graphics.textureToCanvas(texture, force);
}
declare class OffscreenCanvas { };
function isImageBitmap(image) {
return (typeof HTMLImageElement !== 'undefined' && image instanceof HTMLImageElement) ||
(typeof HTMLCanvasElement !== 'undefined' && image instanceof HTMLCanvasElement) ||
(typeof OffscreenCanvas !== 'undefined' && image instanceof OffscreenCanvas) ||
(typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap);
}
function isMesh(obj: Object3D): obj is Mesh {
const type = obj.type;
// Note: we don't use the instanceof check here because it's unreliable in certain minification scenarios where it returns false
return type === "Mesh" || type === "SkinnedMesh";
}
// for contact shadows
export function setVisibleInCustomShadowRendering(obj: Object3D, enabled: boolean) {
if (enabled)
obj["needle:rendercustomshadow"] = true;
else {
obj["needle:rendercustomshadow"] = false;
}
}
export function getVisibleInCustomShadowRendering(obj: Object3D): boolean {
if (obj) {
if (obj["needle:rendercustomshadow"] === true) {
return true;
}
else if (obj["needle:rendercustomshadow"] == undefined) {
return true;
}
}
return false;
}
/**
* Get the axis-aligned bounding box of a list of objects.
* @param objects The objects to get the bounding box from.
* @param ignore Objects to ignore when calculating the bounding box. Objects that are invisible (gizmos, helpers, etc.) are excluded by default.
* @param layers The layers to include. Typically the main camera's layers.
* @param result The result box to store the bounding box in. Returns a new box if not passed in.
*/
export function getBoundingBox(objects: Object3D | Object3D[], ignore: ((obj: Object3D) => void | boolean) | Array<Object3D | null | undefined> | undefined = undefined, layers: Layers | undefined | null = undefined, result: Box3 | undefined = undefined): Box3 {
const box = result || new Box3();
box.makeEmpty();
const emptyChildren = [];
function expandByObjectRecursive(obj: Object3D) {
let allowExpanding = true;
// we dont want to check invisible objects
if (!obj.visible) return;
if (useForAutoFit(obj) === false) return;
if (obj.type === "TransformControlsGizmo" || obj.type === "TransformControlsPlane") return;
// ignore Box3Helpers
if (obj instanceof Box3Helper) allowExpanding = false;
if (obj instanceof GridHelper) allowExpanding = false;
// ignore GroundProjectedEnv
if (obj instanceof GroundedSkybox) allowExpanding = false;
if ((obj as any).isGizmo === true) allowExpanding = false;
// // Ignore shadow catcher geometry
if ((obj as Mesh).material instanceof ShadowMaterial) allowExpanding = false;
// ONLY fit meshes
if (!(isMesh(obj))) allowExpanding = false;
// Layer test, typically with the main camera
if (layers && obj.layers.test(layers) === false) allowExpanding = false;
if (allowExpanding) {
// Ignore things parented to the camera + ignore the camera
if (ignore && Array.isArray(ignore) && ignore?.includes(obj)) return;
else if (typeof ignore === "function") {
if (ignore(obj) === true) return;
}
}
// We don't want to fit UI objects
if (obj["isUI"] === true) return;
// If we encountered some geometry that should be ignored
// Then we don't want to use that for expanding the view
if (allowExpanding) {
// Temporary override children
const children = obj.children;
obj.children = emptyChildren;
// TODO: validate that object doesn't contain NaN values
const pos = obj.position;
const scale = obj.scale;
if (Number.isNaN(pos.x) || Number.isNaN(pos.y) || Number.isNaN(pos.z)) {
console.warn(`Object \"${obj.name}\" has NaN values in position or scale.... will ignore it`, pos, scale);
return;
}
// Sanitize for the three.js method that only checks for undefined here
// @ts-ignore
if (obj.geometry === null) obj.geometry = undefined;
box.expandByObject(obj, true);
obj.children = children;
}
for (const child of obj.children) {
expandByObjectRecursive(child);
}
}
let hasAnyObject = false;
if (!Array.isArray(objects))
objects = [objects];
for (const object of objects) {
if (!object) continue;
hasAnyObject = true;
object.updateMatrixWorld();
expandByObjectRecursive(object);
}
if (!hasAnyObject) {
console.warn("No objects to fit camera to...");
return box;
}
return box;
}
/**
* Fits an object into a bounding volume. The volume is defined by a Box3 in world space.
* @param obj the object to fit
* @param volume the volume to fit the object into
* @param opts options for fitting
*/
export function fitObjectIntoVolume(obj: Object3D, volume: Box3, opts?: {
/** Objects to ignore when calculating the obj's bounding box */
ignore?: Object3D[],
/** when `true` aligns the objects position to the volume ground
* @default true
*/
position?: boolean
/** when `true` scales the object to fit the volume
* @default true
*/
scale?: boolean,
}): {
/** The object's bounding box before fitting */
boundsBefore: Box3,
/** The scale that was applied to the object */
scale: Vector3,
} {
const box = getBoundingBox([obj], opts?.ignore);
const boundsSize = new Vector3();
box.getSize(boundsSize);
const boundsCenter = new Vector3();
box.getCenter(boundsCenter);
const targetSize = new Vector3();
volume.getSize(targetSize);
const targetCenter = new Vector3();
volume.getCenter(targetCenter);
const scale = new Vector3();
scale.set(targetSize.x / boundsSize.x, targetSize.y / boundsSize.y, targetSize.z / boundsSize.z);
const minScale = Math.min(scale.x, scale.y, scale.z);
const useScale = opts?.scale !== false;
if (useScale) {
setWorldScale(obj, getWorldScale(obj).multiplyScalar(minScale));
}
if (opts?.position !== false) {
const boundsBottomPosition = new Vector3();
box.getCenter(boundsBottomPosition);
boundsBottomPosition.y = box.min.y;
const targetBottomPosition = new Vector3();
volume.getCenter(targetBottomPosition);
targetBottomPosition.y = volume.min.y;
const offset = targetBottomPosition.clone().sub(boundsBottomPosition);
if (useScale) offset.multiplyScalar(minScale);
setWorldPosition(obj, getWorldPosition(obj).add(offset));
}
return {
boundsBefore: box,
scale,
}
}
declare type PlaceOnSurfaceResult = {
/** The offset from the object bounds to the pivot */
offset: Vector3,
/** The object's bounding box */
bounds: Box3
}
/**
* Place an object on a surface. This will calculate the object bounds which might be an expensive operation for complex objects.
* The object will be visually placed on the surface (the object's pivot will be ignored).
* @param obj the object to place on the surface
* @param point the point to place the object on
* @returns the offset from the object bounds to the pivot
*/
export function placeOnSurface(obj: Object3D, point: Vector3): PlaceOnSurfaceResult {
const bounds = getBoundingBox([obj]);
const center = new Vector3();
bounds.getCenter(center);
center.y = bounds.min.y;
const offset = point.clone().sub(center);
const worldPos = getWorldPosition(obj);
setWorldPosition(obj, worldPos.add(offset));
return {
offset,
bounds,
}
}
/**
* Postprocesses the material of an object loaded by {@link FBXLoader}.
* It will apply necessary color conversions, remap shininess to roughness, and turn everything into {@link MeshStandardMaterial} on the object.
* This ensures consistent lighting and shading, including environment effects.
*/
export function postprocessFBXMaterials(obj: Mesh, material: Material | Material[], index?: number, array?: Material[]): boolean {
if (Array.isArray(material)) {
let success = true;
for (let i = 0; i < material.length; i++) {
const res = postprocessFBXMaterials(obj, material[i], i, material);
if (!res) success = false;
}
return success;
}
// ignore if the material is already a MeshStandardMaterial
if (material.type === "MeshStandardMaterial" || material.type === "MeshBasicMaterial") {
return false;
}
// check if the material was already processed
else if (material["material:fbx"] != undefined) {
return true;
}
const newMaterial = new MeshStandardMaterial();
newMaterial["material:fbx"] = material;
const oldMaterial = material as any;
if (oldMaterial) {
// If a map is present then the FBX color should be ignored
// Tested e.g. in Unity and Substance Stager
// https://docs.unity3d.com/2020.1/Documentation/Manual/FBXImporter-Materials.html#:~:text=If%20a%20diffuse%20Texture%20is%20set%2C%20it%20ignores%20the%20diffuse%20color%20(this%20matches%20how%20it%20works%20in%20Autodesk®%20Maya®%20and%20Autodesk®%203ds%20Max®)
if (!oldMaterial.map)
newMaterial.color.copyLinearToSRGB(oldMaterial.color);
else newMaterial.color.set(1, 1, 1);
newMaterial.emissive.copyLinearToSRGB(oldMaterial.emissive);
newMaterial.emissiveIntensity = oldMaterial.emissiveIntensity;
newMaterial.opacity = oldMaterial.opacity;
newMaterial.displacementScale = oldMaterial.displacementScale;
newMaterial.transparent = oldMaterial.transparent;
newMaterial.bumpMap = oldMaterial.bumpMap;
newMaterial.aoMap = oldMaterial.aoMap;
newMaterial.map = oldMaterial.map;
newMaterial.displacementMap = oldMaterial.displacementMap;
newMaterial.emissiveMap = oldMaterial.emissiveMap;
newMaterial.normalMap = oldMaterial.normalMap;
newMaterial.envMap = oldMaterial.envMap;
newMaterial.alphaMap = oldMaterial.alphaMap;
newMaterial.metalness = oldMaterial.reflectivity;
newMaterial.vertexColors = oldMaterial.vertexColors;
if (oldMaterial.shininess) {
// from blender source code
// https://github.com/blender/blender-addons/blob/5e66092bcbe0df6855b3fa814b4826add8b01360/io_scene_fbx/import_fbx.py#L1442
// https://github.com/blender/blender-addons/blob/main/io_scene_fbx/import_fbx.py#L2060
newMaterial.roughness = 1.0 - (Math.sqrt(oldMaterial.shininess) / 10.0);
}
newMaterial.needsUpdate = true;
}
if (index === undefined) {
obj.material = newMaterial;
}
else {
array![index] = newMaterial;
}
return true;
}