UNPKG

@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.

412 lines (383 loc) • 15.5 kB
import { createLoaders } from "@needle-tools/gltf-progressive"; import { BoxGeometry, BufferGeometry, Color, ColorRepresentation, CylinderGeometry, DoubleSide, ExtrudeGeometry, Group, Material, Mesh, MeshBasicMaterial, MeshStandardMaterial, Object3D, PlaneGeometry, Shape, SphereGeometry, Sprite, SpriteMaterial, Texture } from "three" import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js"; import { Font, FontLoader } from "three/examples/jsm/loaders/FontLoader.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import type { Vec3 } from "./engine_types.js"; export enum PrimitiveType { /** * A quad with a width and height of 1 facing the positive Z axis */ Quad = 0, /** * A cube with a width, height, and depth of 1 */ Cube = 1, Sphere = 2, Cylinder = 3, RoundedCube = 10, } export type PrimitiveTypeNames = keyof typeof PrimitiveType; /** * Options to create an object. Used by {@link ObjectUtils.createPrimitive} */ export type ObjectOptions = { /** * The parent object to add the created object to */ parent?: Object3D, /** * The name of the object */ name?: string, /** The material to apply to the object */ material?: Material, /** The color of the object. This color will only be used if no material is provided */ color?: ColorRepresentation, /** The texture will applied to the material's main texture slot e.g. `material.map` if any is passed in */ texture?: Texture, /** * The position of the object in local space */ position?: Vec3 | [number, number, number], /** The rotation of the object in local space */ rotation?: Vec3 | [number, number, number], /** * The scale of the object in local space */ scale?: Vec3 | number | [number, number, number], /** * If the object should receive shadows * @default true */ receiveShadow?: boolean, /** * If the object should cast shadows * @default true */ castShadow?: boolean, } /** * Options to create a 3D text object. Used by {@link ObjectUtils.createText} */ export type TextOptions = Omit<ObjectOptions, "texture"> & { /** * Optional: The font to use for the text. If not provided, the default font will be used */ font?: Font, /** * If the font is not provided, the familyFamily can be used to load a font from the default list */ familyFamily?: "OpenSans" | "Helvetiker";// "Optimer" | "Gentilis" | "DroidSans" /** * Optional: The depth of the text. * @default .1 */ depth?: number; /** * Optional: If the text should have a bevel effect * @default false */ bevel?: boolean; /** * Invoked when the font geometry is loaded */ onGeometry?: (obj: Mesh) => void; } /** * Utility class to create primitive objects * @example * ```typescript * const cube = ObjectUtils.createPrimitive("Cube", { name: "Cube", position: { x: 0, y: 0, z: 0 } }); * ``` */ export class ObjectUtils { /** * Creates a 3D text object * @param text The text to display * @param opts Options to create the object */ static createText(text: string, opts?: TextOptions): Mesh { let geometry: BufferGeometry | null = null; const font: Font | Promise<Font> = opts?.font || loadFont(opts?.familyFamily || null); if (font instanceof Font) { geometry = this.#createTextGeometry(text, font, opts); } else if (geometry == null) { geometry = new BufferGeometry(); } const color = opts?.color || 0xffffff; const mesh = new Mesh(geometry, opts?.material ?? new MeshStandardMaterial({ color: color })); this.applyDefaultObjectOptions(mesh, opts); if (font instanceof Promise) { font.then(res => { mesh.geometry = this.#createTextGeometry(text, res, opts); if (opts?.onGeometry) opts.onGeometry(mesh); }); } else { if (opts?.onGeometry) opts.onGeometry(mesh); } return mesh; } static #createTextGeometry(text: string, font: Font, opts?: TextOptions) { const depth = opts?.depth || .1; const geo = new TextGeometry(text, { font, size: 1, depth: depth, height: depth, bevelEnabled: opts?.bevel || false, bevelThickness: .01, bevelOffset: .01, bevelSize: .01, }); return geo; } /** * Creates an occluder object that only render depth but not color * @param type The type of primitive to create * @returns The created object */ static createOccluder(type: PrimitiveTypeNames): Mesh { const occluderMaterial = new MeshBasicMaterial({ colorWrite: false, depthWrite: true, side: DoubleSide }); return this.createPrimitive(type, { material: occluderMaterial }); } /** Creates a primitive object like a Cube or Sphere * @param type The type of primitive to create * @param opts Options to create the object * @returns The created object */ static createPrimitive(type: "ShaderBall", opts?: ObjectOptions): Group; static createPrimitive(type: PrimitiveType | PrimitiveTypeNames, opts?: ObjectOptions): Mesh; static createPrimitive(type: PrimitiveType | PrimitiveTypeNames | "ShaderBall", opts?: ObjectOptions): Mesh | Group { let obj: Mesh | Group; const color = opts?.color || 0xffffff; switch (type) { case "Quad": case PrimitiveType.Quad: { const quadGeo = new PlaneGeometry(1, 1, 1, 1); const mat = opts?.material ?? new MeshStandardMaterial({ color: color }); if (opts?.texture && "map" in mat) mat.map = opts.texture; obj = new Mesh(quadGeo, mat); obj.name = "Quad"; } break; case "Cube": case PrimitiveType.Cube: { const boxGeo = new BoxGeometry(1, 1, 1); const mat = opts?.material ?? new MeshStandardMaterial({ color: color }); if (opts?.texture && "map" in mat) mat.map = opts.texture; obj = new Mesh(boxGeo, mat); obj.name = "Cube"; } break; case PrimitiveType.RoundedCube: case "RoundedCube": { const boxGeo = createBoxWithRoundedEdges(1, 1, 1, .1, 2); const mat = opts?.material ?? new MeshStandardMaterial({ color: color }); if (opts?.texture && "map" in mat) mat.map = opts.texture; obj = new Mesh(boxGeo, mat); obj.name = "RoundedCube"; } break; case "Sphere": case PrimitiveType.Sphere: { const sphereGeo = new SphereGeometry(.5, 16, 16); const mat = opts?.material ?? new MeshStandardMaterial({ color: color }); if (opts?.texture && "map" in mat) mat.map = opts.texture; obj = new Mesh(sphereGeo, mat); obj.name = "Sphere"; } break; case "Cylinder": case PrimitiveType.Cylinder: { const geo = new CylinderGeometry(.5, .5, 1, 32); const mat = opts?.material ?? new MeshStandardMaterial({ color: color }); if (opts?.texture && "map" in mat) mat.map = opts.texture; obj = new Mesh(geo, mat); obj.name = "Cylinder"; } break; case "ShaderBall": { obj = new Group(); obj.name = "ShaderBall"; loadShaderball(obj, opts); } break; } this.applyDefaultObjectOptions(obj, opts); return obj; } /** * Creates a Sprite object * @param opts Options to create the object * @returns The created object */ static createSprite(opts?: Omit<ObjectOptions, "material">): Sprite { const color = 0xffffff; const mat = new SpriteMaterial({ color: color }); if (opts?.texture && "map" in mat) mat.map = opts.texture; const sprite = new Sprite(mat); this.applyDefaultObjectOptions(sprite, opts); return sprite; } private static applyDefaultObjectOptions(obj: Object3D, opts?: ObjectOptions) { obj.receiveShadow = true; obj.castShadow = true; if (opts?.name) obj.name = opts.name; if (opts?.position) { if (Array.isArray(opts.position)) obj.position.set(opts.position[0], opts.position[1], opts.position[2]); else obj.position.set(opts.position.x, opts.position.y, opts.position.z); } if (opts?.rotation) { if (Array.isArray(opts.rotation)) obj.rotation.set(opts.rotation[0], opts.rotation[1], opts.rotation[2]); else obj.rotation.set(opts.rotation.x, opts.rotation.y, opts.rotation.z); } if (opts?.scale) { if (typeof opts.scale === "number") obj.scale.set(opts.scale, opts.scale, opts.scale); else if (Array.isArray(opts.scale)) { obj.scale.set(opts.scale[0], opts.scale[1], opts.scale[2]); } else { obj.scale.set(opts.scale.x, opts.scale.y, opts.scale.z); } } if (opts?.receiveShadow != undefined) { obj.receiveShadow = opts.receiveShadow; } if (opts?.castShadow != undefined) { obj.castShadow = opts.castShadow; } if (opts?.parent) { opts.parent.add(obj); } } } function createBoxWithRoundedEdges(width: number, height: number, _depth: number, radius0: number, smoothness: number) { const shape = new Shape(); const eps = 0.00001; const radius = radius0 - eps; shape.absarc(eps, eps, eps, -Math.PI / 2, -Math.PI, true); shape.absarc(eps, height - radius * 2, eps, Math.PI, Math.PI / 2, true); shape.absarc(width - radius * 2, height - radius * 2, eps, Math.PI / 2, 0, true); shape.absarc(width - radius * 2, eps, eps, 0, -Math.PI / 2, true); const geometry = new ExtrudeGeometry(shape, { bevelEnabled: true, bevelSegments: smoothness * 2, steps: 1, bevelSize: radius, bevelThickness: radius0, curveSegments: smoothness, }); geometry.scale(1, 1, 1 - radius0) geometry.center(); geometry.computeVertexNormals(); return geometry; } const fontsDict = new Map<string, Font | Promise<Font>>(); function loadFont(family: string | null): Font | Promise<Font> { let url: string = ""; switch (family) { default: case "OpenSans": url = "https://cdn.needle.tools/static/fonts/facetype/Open Sans_Regular_ascii.json"; break; case "Helvetiker": url = "https://raw.githubusercontent.com/mrdoob/three.js/master/examples/fonts/helvetiker_regular.typeface.json"; break; } if (fontsDict.has(url)) { const res = fontsDict.get(url); if (res) return res; } const loader = new FontLoader(); const promise = new Promise<Font>((resolve, reject) => { loader.load(url, res => { fontsDict.set(url, res); resolve(res); }, undefined, reject); }); fontsDict.set(url, promise); return promise; } let __shaderballIsLoading = false; let __shaderballPromise: Promise<Object3D> | null = null; /** Loads the shaderball mesh in the background and assigns it to the provided mesh */ function loadShaderball(group: Group, opts?: ObjectOptions) { // invoke the loading process ONCE if (__shaderballPromise === null) { // to make the autofit work we need to insert a placeholder the first time this is invoked const url = "https://cdn.needle.tools/static/models/shaderball.glb"; const loader = new GLTFLoader(); const loaders = createLoaders(null); loader.setDRACOLoader(loaders.dracoLoader); loader.setKTX2Loader(loaders.ktx2Loader); __shaderballIsLoading = true; __shaderballPromise = loader.loadAsync(url) .then(res => { const scene = res.scene; // the shaderball pivot is on the bottom and has a size of 1x1x1 // to match the behaviour from all other object's we move it down by 0.5 scene.position.y -= .5; return scene; }) .catch(err => { // Handle case if the shaderball mesh fails to load or is unavailable for any reason console.warn("Failed to load shaderball mesh: " + err.message); return createShaderballPlaceholder(); }) .finally(() => { __shaderballIsLoading = false; }); } if (__shaderballIsLoading) { // if the shaderball is still loading, insert a placeholder // this is mainly so that the autofit works correctly const placeholder = createShaderballPlaceholder(); placeholder.name = "ShaderBall-Placeholder"; const mesh = placeholder.children[0] as Mesh; if (mesh?.type === "Mesh") updateShaderballMaterial(mesh, opts); group.add(placeholder); } // and for every object that needs it, clone the loaded mesh __shaderballPromise.then(res => { // remove placeholders group.children.forEach(c => { if (c.name === "ShaderBall-Placeholder") group.remove(c); }); // clone the loaded mesh const instance = res.clone(); const mesh = instance.children[0] as Mesh; if (mesh?.type === "Mesh") { updateShaderballMaterial(mesh, opts); } group.add(instance); }); } function updateShaderballMaterial(mesh: Mesh, opts?: ObjectOptions) { const needCustomMaterial = opts?.color || opts?.material || opts?.texture; if (needCustomMaterial) { const mat = opts?.material ?? (mesh.material as Material)?.clone() ?? new MeshStandardMaterial(); if (opts.color && "color" in mat && mat.color instanceof Color) mat.color.set(opts.color); if (opts?.texture && "map" in mat) mat.map = opts.texture; mesh.material = mat; } } function createShaderballPlaceholder() { return new Group().add(ObjectUtils.createPrimitive("Sphere", { material: new MeshBasicMaterial({ transparent: true, opacity: .1 }) })); }