diff --git a/src/App3D.ts b/src/App3D.ts index e69de29b..f83d3591 100644 --- a/src/App3D.ts +++ b/src/App3D.ts @@ -0,0 +1,1287 @@ +// TODO createCompositor +// TODO Dispose test. geoCache test. +// TODO Tonemapping exposure +// TODO fitModel. +// TODO Particle ? +import Renderer, { RendererOpts } from './Renderer'; +import Scene, { RenderList } from './Scene'; +import Timeline from './Timeline'; +import CubeGeo from './geometry/Cube'; +import SphereGeo from './geometry/Sphere'; +import PlaneGeo from './geometry/Plane'; +import ParametricSurfaceGeo, { ParametricSurfaceGeometryOpts } from './geometry/ParametricSurface'; +import Texture2D, { Texture2DOpts } from './Texture2D'; +import TextureCube, { CubeTarget, TextureCubeOpts } from './TextureCube'; +import Texture, { TextureImageSource } from './Texture'; +import Mesh from './Mesh'; +import Material, { MaterialOpts } from './Material'; +import PerspectiveCamera from './camera/Perspective'; +import OrthographicCamera from './camera/Orthographic'; +import Vector3 from './math/Vector3'; +import { GLTFLoadResult, load as loadGLTF } from './loader/GLTF'; +import ClayNode from './Node'; +import DirectionalLight from './light/Directional'; +import PointLight from './light/Point'; +import SpotLight from './light/Spot'; +import AmbientLight from './light/Ambient'; +import AmbientCubemapLight from './light/AmbientCubemap'; +import AmbientSHLight from './light/AmbientSH'; +import ShadowMapPass from './prePass/ShadowMap'; +import LRUCache from './core/LRU'; +import Skybox from './plugin/Skybox'; +import * as util from './core/util'; +import * as shUtil from './util/sh'; +import * as textureUtil from './util/texture'; +import * as colorUtil from './core/color'; + +import { Notifier } from './core'; +import GPUResourceManager from './app/GPUResourceManager'; +import Camera from './Camera'; +import { EventManager } from './app/EventManager'; +import type Renderable from './Renderable'; +import Shader from './Shader'; +import StandardShader from './shader/StandardShader'; +import Geometry from './Geometry'; +import { Color } from './core/type'; + +interface App3DGraphicOpts { + /** + * If enable shadow + */ + shadow?: boolean; + /** + * If use linear color space + */ + linear?: boolean; + + /** + * If enable ACES tone mapping. + */ + tonemapping?: boolean; +} +interface App3DOpts { + devicePixelRatio?: number; + width?: number; + height?: number; + + /** + * If render automatically each frame. + */ + autoRender?: boolean; + + /** + * If not init immediately. Should call init method manually. + * + * App will start the loop after promise returned from init resolved. + */ + lazyInit?: boolean; + /** + * If enable mouse/touch event. It will slow down the system if geometries are complex. + */ + event?: boolean; + /** + * Graphic configuration including shadow, color space. + */ + graphic?: App3DGraphicOpts; + + /** + * Attributes for creating gl context + */ + glAttributes?: { + alpha?: boolean; + depth?: boolean; + stencil?: boolean; + antialias?: boolean; + premultipliedAlpha?: boolean; + preserveDrawingBuffer?: boolean; + }; +} + +type CreateMaterialConfig = Partial & { + // Textures opts + textureConvertToPOT?: boolean; + textureFlipY?: boolean; + + textureLoaded?: (textureName: string, texture: Texture) => void; + texturesReady?: (textures: Texture[]) => void; +} & { + // Uniform values + [key: string]: any; +}; +/** + * Using App3D is a much more convenient way to create and manage your 3D application. + * + * It provides the abilities to: + * + * + Manage application loop and rendering. + * + Collect GPU resource automatically without memory leak concern. + * + Mouse event management. + * + Create scene objects, materials, textures with simpler code. + * + Load models with one line of code. + * + Promised interfaces. + * + * Here is a basic example to create a rotating cube. + * +```js +const app = clay.application.create('#viewport', { + init: function (app) { + // Create a perspective camera. + // First parameter is the camera position. Which is in front of the cube. + // Second parameter is the camera lookAt target. Which is the origin of the world, and where the cube puts. + this._camera = app.createCamera([0, 2, 5], [0, 0, 0]); + // Create a sample cube + this._cube = app.createCube(); + // Create a directional light. The direction is from top right to left bottom, away from camera. + this._mainLight = app.createDirectionalLight([-1, -1, -1]); + }, + loop: function (app) { + // Simply rotating the cube every frame. + this._cube.rotation.rotateY(app.frameTime / 1000); + } +}); +``` + * @param dom Container dom element or a selector string that can be used in `querySelector` + * @param option Options in creating app3D + */ +class App3D extends Notifier { + private _container: HTMLElement; + + private _renderer: Renderer; + private _scene: Scene; + private _timeline: Timeline; + private _shadowPass?: ShadowMapPass; + + private _gpuResourceManager: GPUResourceManager; + private _eventManager?: EventManager; + + private _geoCache = new LRUCache(20); + private _texCache = new LRUCache(20); + + private _graphicOpts: App3DGraphicOpts; + + private _inRender = false; + private _disposed = false; + private _autoRender; + private _inited = false; + + private _defaultShader = new StandardShader(); + + constructor(container: HTMLElement | string, opts?: App3DOpts) { + super(); + opts = Object.assign({}, opts); + + this._autoRender = util.optional(opts.autoRender, true); + const graphic = (this._graphicOpts = opts.graphic || {}); + const glAttributes = opts.glAttributes || {}; + + if (typeof container === 'string') { + container = window.document.querySelector(container) as HTMLElement; + } + + if (!container) { + throw new Error('Invalid dom'); + } + + this._container = container; + + const isDomCanvas = + !container.nodeName || // Not in dom environment + container.nodeName.toUpperCase() === 'CANVAS'; + + const rendererOpts: Partial = {}; + isDomCanvas && (rendererOpts.canvas = container as HTMLCanvasElement); + opts.devicePixelRatio && (rendererOpts.devicePixelRatio = opts.devicePixelRatio); + + ( + [ + 'alpha', + 'depth', + 'stencil', + 'antialias', + 'premultipliedAlpha', + 'preserveDrawingBuffer' + ] as const + ).forEach(function (attrName) { + if (glAttributes[attrName] != null) { + rendererOpts[attrName] = glAttributes[attrName]; + } + }); + + const renderer = (this._renderer = new Renderer(rendererOpts)); + const scene = (this._scene = new Scene()); + + const width = opts.width || container.clientWidth; + const height = opts.height || container.clientHeight; + + const timeline = (this._timeline = new Timeline()); + + this._gpuResourceManager = new GPUResourceManager(renderer); + + if (graphic.shadow) { + this._shadowPass = new ShadowMapPass(); + } + + if (opts.event) { + this._eventManager = new EventManager(container, renderer, this._scene); + } + !isDomCanvas && container.appendChild(renderer.canvas); + + renderer.resize(width, height); + + timeline.start(); + + if (!opts.lazyInit) { + this._doInit(); + } + + scene.on( + 'beforerender', + (renderer: Renderer, scene: Scene, camera: Camera, renderList: RenderList) => { + if (this._inRender) { + // Only update graphic options when using #render function. + (['opaque', 'transparent'] as const).forEach((type) => { + this._updateGraphicOptions(opts!.graphic!, renderList[type], false); + }); + } + } + ); + } + + /** + * Init app3D + * + * @param prepare Prepare before an intialization. + * Return a promise that should be resolved when app is ready + */ + init(prepare?: () => Promise) { + if (this._inited) { + return; + } + + Promise.resolve(prepare && prepare()).then(() => { + this._doInit(); + }); + } + + /** + * Alias for app3D.on('loop') + */ + loop(cb: (frameTime: number) => void) { + this.on('loop', cb); + } + + private _doInit() { + this._inited = true; + + let gFrameTime = 0; + let gElapsedTime = 0; + this._timeline.on('frame', (frameTime: number) => { + gFrameTime = frameTime; + gElapsedTime += frameTime; + + const camera = this._scene.getMainCamera(); + if (camera instanceof PerspectiveCamera) { + camera.aspect = this._renderer.getViewportAspect(); + } + + this.trigger('loop', frameTime); + + if (this._autoRender) { + this.render(); + } + + this._gpuResourceManager.collect(this._scene); + }); + } + + private _updateGraphicOptions( + graphicOpts: App3DGraphicOpts, + list: Renderable[], + isSkybox?: boolean + ) { + const enableTonemapping = !!graphicOpts.tonemapping; + const isLinearSpace = !!graphicOpts.linear; + + let prevMaterial; + + for (let i = 0; i < list.length; i++) { + const mat = list[i].material; + if (mat === prevMaterial) { + continue; + } + + enableTonemapping + ? mat.define('fragment', 'TONEMAPPING') + : mat.undefine('fragment', 'TONEMAPPING'); + if (isLinearSpace) { + let decodeSRGB = true; + if (isSkybox && mat.get('environmentMap') && !mat.get('environmentMap').sRGB) { + decodeSRGB = false; + } + decodeSRGB && mat.define('fragment', 'SRGB_DECODE'); + mat.define('fragment', 'SRGB_ENCODE'); + } else { + mat.undefine('fragment', 'SRGB_DECODE'); + mat.undefine('fragment', 'SRGB_ENCODE'); + } + + prevMaterial = mat; + } + } + + private _doRender(renderer: Renderer, scene: Scene) { + const camera = scene.getMainCamera(); + renderer.render(scene, camera, true); + } + + /** + * Do render + */ + render() { + this._inRender = true; + + this.trigger('beforerender'); + const scene = this._scene; + const renderer = this._renderer; + const shadowPass = this._shadowPass; + + scene.update(); + const skyboxList = []; + const skybox = Skybox.getSceneSkybox(scene); + skybox && skyboxList.push(skybox); + + this._updateGraphicOptions(this._graphicOpts, skyboxList, true); + // Render shadow pass + shadowPass && shadowPass.render(renderer, scene, undefined, true); + + this._doRender(renderer, scene); + + this.trigger('afterrender'); + this._inRender = false; + } + + /** + * Load a texture from image or string. + * @example + * app.loadTexture('diffuseMap.jpg') + * .then(function (texture) { + * material.set('diffuseMap', texture); + * }); + */ + loadTexture( + urlOrImg: string | TextureImageSource, + opts: Partial & { + exposure?: number; + }, + useCache?: boolean + ): Promise { + const key = getKeyFromImageLike(urlOrImg); + const texCache = this._texCache; + if (useCache) { + if (texCache.get(key)) { + return texCache.get(key) as Promise; + } + } + const promise = new Promise((resolve, reject) => { + const texture = this.loadTextureSync(urlOrImg, opts); + if (!texture.isRenderable()) { + texture.success(() => { + if (this._disposed) { + return; + } + resolve(texture); + }); + texture.error(() => { + if (this._disposed) { + return; + } + reject(); + }); + } else { + resolve(texture); + } + }); + if (useCache) { + this._texCache.put(key, promise); + } + return promise as Promise; + } + + /** + * Create a texture from image or string synchronously. Texture can be use directly and don't have to wait for it's loaded. + * @param {ImageLike} img + * @param {Object} [opts] Texture options. + * @param {boolean} [opts.flipY=true] If flipY. See {@link clay.Texture.flipY} + * @param {boolean} [opts.convertToPOT=false] Force convert None Power of Two texture to Power of two so it can be tiled. + * @param {number} [opts.anisotropic] Anisotropic filtering. See {@link clay.Texture.anisotropic} + * @param {number} [opts.wrapS=clay.Texture.REPEAT] See {@link clay.Texture.wrapS} + * @param {number} [opts.wrapT=clay.Texture.REPEAT] See {@link clay.Texture.wrapT} + * @param {number} [opts.minFilter=clay.Texture.LINEAR_MIPMAP_LINEAR] See {@link clay.Texture.minFilter} + * @param {number} [opts.magFilter=clay.Texture.LINEAR] See {@link clay.Texture.magFilter} + * @param {number} [opts.exposure] Only be used when source is a HDR image. + * @return {clay.Texture2D} + * @example + * const texture = app.loadTexture('diffuseMap.jpg', { + * anisotropic: 8, + * flipY: false + * }); + * material.set('diffuseMap', texture); + */ + loadTextureSync( + urlOrImg: string | TextureImageSource, + opts?: Partial & { + exposure?: number; + } + ): Texture2D { + let texture = new Texture2D(opts); + if (typeof urlOrImg === 'string') { + if (urlOrImg.match(/.hdr$|^data:application\/octet-stream/)) { + texture = textureUtil.loadTexture( + urlOrImg, + { + exposure: opts && opts.exposure, + fileType: 'hdr' + }, + function () { + texture.dirty(); + texture.trigger('success'); + } + ); + + Object.assign(texture, opts); + } else { + texture.load(urlOrImg); + } + } else if (isImageLikeElement(urlOrImg)) { + texture.image = urlOrImg; + texture.dynamic = urlOrImg instanceof HTMLVideoElement; + } + return texture; + } + + /** + * Create a texture from image or string synchronously. Texture can be use directly and don't have to wait for it's loaded. + * @param {ImageLike} img + * @param {Object} [opts] Texture options. + * @param {boolean} [opts.flipY=false] If flipY. See {@link clay.Texture.flipY} + * @return {Promise} + * @example + * app.loadTextureCube({ + * px: 'skybox/px.jpg', py: 'skybox/py.jpg', pz: 'skybox/pz.jpg', + * nx: 'skybox/nx.jpg', ny: 'skybox/ny.jpg', nz: 'skybox/nz.jpg' + * }).then(function (texture) { + * skybox.setEnvironmentMap(texture); + * }) + */ + loadTextureCube( + imgList: Record, + opts?: Partial + ): Promise { + const textureCube = this.loadTextureCubeSync(imgList, opts); + return new Promise(function (resolve, reject) { + if (textureCube.isRenderable()) { + resolve(textureCube); + } else { + textureCube + .success(function () { + resolve(textureCube); + }) + .error(function () { + reject(); + }); + } + }); + } + + /** + * Create a texture from image or string synchronously. Texture can be use directly and don't have to wait for it's loaded. + * @param {ImageLike} img + * @param {Object} [opts] Texture options. + * @param {boolean} [opts.flipY=false] If flipY. See {@link clay.Texture.flipY} + * @return {clay.TextureCube} + * @example + * const texture = app.loadTextureCubeSync({ + * px: 'skybox/px.jpg', py: 'skybox/py.jpg', pz: 'skybox/pz.jpg', + * nx: 'skybox/nx.jpg', ny: 'skybox/ny.jpg', nz: 'skybox/nz.jpg' + * }); + * skybox.setEnvironmentMap(texture); + */ + loadTextureCubeSync( + imgList: Record, + opts?: Partial + ): TextureCube { + opts = opts || {}; + opts.flipY = opts.flipY || false; + const textureCube = new TextureCube(opts); + if ( + !imgList || + !imgList.px || + !imgList.nx || + !imgList.py || + !imgList.ny || + !imgList.pz || + !imgList.nz + ) { + throw new Error('Invalid cubemap format. Should be an object including px,nx,py,ny,pz,nz'); + } + if (typeof imgList.px === 'string') { + textureCube.load(imgList as Record); + } else { + textureCube.image = Object.assign({}, imgList) as Record; + } + return textureCube; + } + + /** + * Create a material. + * @param {Object|StandardMaterialMRConfig} materialConfig. materialConfig contains `shader`, `transparent` and uniforms that used in corresponding uniforms. + * Uniforms can be `color`, `alpha` `diffuseMap` etc. + * @param {string|clay.Shader} Default to be standard shader with metalness and roughness workflow. + * @param {boolean} [transparent=false] If material is transparent. + * @param {boolean} [textureConvertToPOT=false] Force convert None Power of Two texture to Power of two so it can be tiled. + * @param {boolean} [textureFlipY=true] If flip y of texture. + * @param {Function} [textureLoaded] Callback when single texture loaded. + * @param {Function} [texturesReady] Callback when all texture loaded. + * @return {clay.Material} + */ + createMaterial(matConfig: CreateMaterialConfig) { + matConfig = matConfig || {}; + const shader = matConfig.shader || this._defaultShader; + const material = new Material({ + shader + }); + if (matConfig.name) { + material.name = matConfig.name; + } + + const uniforms = material.uniforms; + const texturesLoading: Promise[] = []; + function makeTextureSetter(key: string) { + return function (texture: Texture) { + material.setUniform(key, texture); + matConfig.textureLoaded && matConfig.textureLoaded(key, texture); + return texture; + }; + } + Object.keys(uniforms).forEach((uniformName) => { + if (uniforms[uniformName]) { + const val = matConfig[uniformName]; + if ( + (uniforms[uniformName].type === 't' || isImageLikeElement(val)) && + !(val instanceof Texture) + ) { + // Try to load a texture. + texturesLoading.push( + this.loadTexture(val, { + convertToPOT: matConfig.textureConvertToPOT || false, + flipY: util.optional(matConfig.textureFlipY, true) + }).then(makeTextureSetter(uniformName)) + ); + } else { + material.setUniform(uniformName, val); + } + } + }); + + const texturesReady = matConfig.texturesReady; + if (texturesReady) { + Promise.all(texturesLoading).then(function (textures) { + texturesReady(textures); + }); + } + if (matConfig.transparent) { + material.depthTest = false; + material.transparent = true; + } + + return material; + } + + /** + * Create a cube mesh and add it to the scene or the given parent node. + * @param material + * @param parentNode Parent node to append. Default to be scene. + * @param subdivision Subdivision of cube. + * Can be a number to represent both width, height and depth dimensions. Or an array to represent them respectively. + * @example + * // Create a white cube. + * app.createCube() + */ + createCube( + material: CreateMaterialConfig | Material, + parentNode?: ClayNode, + subdiv?: number | number[] + ) { + subdiv = subdiv || 1; + if (typeof subdiv === 'number') { + subdiv = [subdiv, subdiv, subdiv]; + } + + const geoKey = 'cube-' + subdiv.join('-'); + let cube = this._geoCache.get(geoKey); + if (!cube) { + cube = new CubeGeo({ + widthSegments: subdiv[0], + heightSegments: subdiv[1], + depthSegments: subdiv[2] + }); + cube.generateTangents(); + this._geoCache.put(geoKey, cube); + } + return this.createMesh(cube, material, parentNode); + } + + /** + * Create a cube mesh that camera is inside the cube. + * @function + * @param {Object|clay.Material} [material] + * @param {clay.Node} [parentNode] Parent node to append. Default to be scene. + * @param {Array.|number} [subdivision=1] Subdivision of cube. + * Can be a number to represent both width, height and depth dimensions. Or an array to represent them respectively. + * @return {clay.Mesh} + * @example + * // Create a white cube inside. + * app.createCubeInside() + */ + createCubeInside( + material: CreateMaterialConfig | Material, + parentNode?: ClayNode, + subdiv?: number | number[] + ) { + if (subdiv == null) { + subdiv = 1; + } + if (typeof subdiv === 'number') { + subdiv = [subdiv, subdiv, subdiv]; + } + const geoKey = 'cubeInside-' + subdiv.join('-'); + let cube = this._geoCache.get(geoKey); + if (!cube) { + cube = new CubeGeo({ + inside: true, + widthSegments: subdiv[0], + heightSegments: subdiv[1], + depthSegments: subdiv[2] + }); + cube.generateTangents(); + this._geoCache.put(geoKey, cube); + } + + return this.createMesh(cube, material, parentNode); + } + + /** + * Create a sphere mesh and add it to the scene or the given parent node. + * @param material + * @param parentNode Parent node to append. Default to be scene. + * @param subdivision Subdivision of sphere. + * @example + * // Create a semi-transparent sphere. + * app.createSphere({ + * color: [0, 0, 1], + * transparent: true, + * alpha: 0.5 + * }) + */ + createSphere(material: CreateMaterialConfig | Material, parentNode?: ClayNode, subdiv?: number) { + if (subdiv == null) { + subdiv = 20; + } + const geoKey = 'sphere-' + subdiv; + let sphere = this._geoCache.get(geoKey); + if (!sphere) { + sphere = new SphereGeo({ + widthSegments: subdiv * 2, + heightSegments: subdiv + }); + sphere.generateTangents(); + this._geoCache.put(geoKey, sphere); + } + return this.createMesh(sphere, material, parentNode); + } + + // TODO may be modified? + /** + * Create a plane mesh and add it to the scene or the given parent node. + * @function + * @param {Object|clay.Material} [material] + * @param {clay.Node} [parentNode] Parent node to append. Default to be scene. + * @param {Array.|number} [subdivision=1] Subdivision of plane. + * Can be a number to represent both width and height dimensions. Or an array to represent them respectively. + * @return {clay.Mesh} + * @example + * // Create a red color plane. + * app.createPlane({ + * color: [1, 0, 0] + * }) + */ + createPlane( + material: CreateMaterialConfig | Material, + parentNode?: ClayNode, + subdiv?: number | number[] + ) { + if (subdiv == null) { + subdiv = 1; + } + if (typeof subdiv === 'number') { + subdiv = [subdiv, subdiv]; + } + const geoKey = 'plane-' + subdiv.join('-'); + let planeGeo = this._geoCache.get(geoKey); + if (!planeGeo) { + planeGeo = new PlaneGeo({ + widthSegments: subdiv[0], + heightSegments: subdiv[1] + }); + planeGeo.generateTangents(); + this._geoCache.put(geoKey, planeGeo); + } + return this.createMesh(planeGeo, material, parentNode); + } + + /** + * Create mesh with parametric surface function + * @param {Object|clay.Material} [material] + * @param {clay.Node} [parentNode] Parent node to append. Default to be scene. + * @param {Object} generator + * @param {Function} generator.x + * @param {Function} generator.y + * @param {Function} generator.z + * @param {Array} [generator.u=[0, 1, 0.05]] + * @param {Array} [generator.v=[0, 1, 0.05]] + * @return {clay.Mesh} + */ + createParametricSurface( + material: CreateMaterialConfig | Material, + parentNode?: ClayNode, + generator?: ParametricSurfaceGeometryOpts['generator'] + ) { + const geo = new ParametricSurfaceGeo({ + generator: generator + }); + geo.generateTangents(); + return this.createMesh(geo, material, parentNode); + } + + /** + * Create a general mesh with given geometry instance and material config. + * @param geometry + */ + createMesh(geometry: Geometry, mat: CreateMaterialConfig | Material, parentNode?: ClayNode) { + const mesh = new Mesh({ + geometry: geometry, + material: mat instanceof Material ? mat : this.createMaterial(mat) + }); + parentNode = parentNode || this._scene; + parentNode.add(mesh); + return mesh; + } + + /** + * Create an empty node + * @param {clay.Node} parentNode + * @return {clay.Node} + */ + createNode(parentNode: ClayNode) { + const node = new ClayNode(); + parentNode = parentNode || this._scene; + parentNode.add(node); + return node; + } + + /** + * Create a perspective or orthographic camera and add it to the scene. + * @param position + * @param target + * @param type Can be 'perspective' or 'orthographic'(in short 'ortho') + * @param extent Extent is available only if type is orthographic. + */ + createCamera( + position: Vector3 | Vector3['array'], + target?: Vector3, + type?: 'ortho' | 'orthographic', + extent?: Vector3 | Vector3['array'] + ): void; + createCamera(position: Vector3 | Vector3['array'], target?: Vector3, type?: 'perspective'): void; + createCamera( + position: Vector3 | Vector3['array'], + target?: Vector3, + type?: 'ortho' | 'orthographic' | 'perspective', + extent?: Vector3 | Vector3['array'] + ) { + let CameraCtor; + let isOrtho = false; + if (type === 'ortho' || type === 'orthographic') { + isOrtho = true; + CameraCtor = OrthographicCamera; + } else { + if (type && type !== 'perspective') { + console.error('Unkown camera type ' + type + '. Use default perspective camera'); + } + CameraCtor = PerspectiveCamera; + } + + const camera = new CameraCtor(); + if (position instanceof Vector3) { + camera.position.copy(position); + } else if (Array.isArray(position)) { + camera.position.setArray(position); + } + + if (Array.isArray(target)) { + target = new Vector3(target[0], target[1], target[2]); + } + if (target instanceof Vector3) { + camera.lookAt(target); + } + + if (extent && isOrtho) { + extent = (extent as Vector3).array || extent; + (camera as OrthographicCamera).left = -extent[0] / 2; + (camera as OrthographicCamera).right = extent[0] / 2; + (camera as OrthographicCamera).top = extent[1] / 2; + (camera as OrthographicCamera).bottom = -extent[1] / 2; + (camera as OrthographicCamera).near = 0; + (camera as OrthographicCamera).far = extent[2]; + } else { + (camera as PerspectiveCamera).aspect = this._renderer.getViewportAspect(); + } + + this._scene.add(camera); + + return camera; + } + + /** + * Create a directional light and add it to the scene. + * @param dir A Vector3 or array to represent the direction. + * @param {Color} [color='#fff'] Color of directional light, default to be white. + * @param {number} [intensity] Intensity of directional light, default to be 1. + * + * @example + * app.createDirectionalLight([-1, -1, -1], '#fff', 2); + */ + createDirectionalLight( + dir: Vector3 | Vector3['array'], + color?: Color | string, + intensity?: number + ) { + const light = new DirectionalLight(); + if (dir instanceof Vector3) { + dir = dir.array; + } + light.position.setArray(dir).negate(); + light.lookAt(Vector3.ZERO); + if (util.isString(color)) { + color = colorUtil.parse(color)!; + } + color != null && (light.color = color); + intensity != null && (light.intensity = intensity); + + this._scene.add(light); + return light; + } + + /** + * Create a spot light and add it to the scene. + * @param {Array.|clay.Vector3} position Position of the spot light. + * @param {Array.|clay.Vector3} [target] Target position where spot light points to. + * @param {number} [range=20] Falloff range of spot light. Default to be 20. + * @param {Color} [color='#fff'] Color of spot light, default to be white. + * @param {number} [intensity=1] Intensity of spot light, default to be 1. + * @param {number} [umbraAngle=30] Umbra angle of spot light. Default to be 30 degree from the middle line. + * @param {number} [penumbraAngle=45] Penumbra angle of spot light. Default to be 45 degree from the middle line. + * + * @example + * app.createSpotLight([5, 5, 5], [0, 0, 0], 20, #900); + */ + createSpotLight( + position: Vector3 | Vector3['array'], + target?: Vector3 | Vector3['array'], + range?: number, + color?: Color | string, + intensity?: number, + umbraAngle?: number, + penumbraAngle?: number + ) { + const light = new SpotLight(); + light.position.setArray(position instanceof Vector3 ? position.array : position); + + if (target instanceof Array) { + target = new Vector3(target[0], target[1], target[2]); + } + if (target instanceof Vector3) { + light.lookAt(target); + } + + if (util.isString(color)) { + color = colorUtil.parse(color)!; + } + range != null && (light.range = range); + color != null && (light.color = color); + intensity != null && (light.intensity = intensity); + umbraAngle != null && (light.umbraAngle = umbraAngle); + penumbraAngle != null && (light.penumbraAngle = penumbraAngle); + + this._scene.add(light); + + return light; + } + + /** + * Create a point light. + * @param {Array.|clay.Vector3} position Position of point light.. + * @param {number} [range=100] Falloff range of point light. + * @param {Color} [color='#fff'] Color of point light. + * @param {number} [intensity=1] Intensity of point light. + */ + createPointLight( + position: Vector3 | Vector3['array'], + range?: number, + color?: Color | string, + intensity?: number + ) { + const light = new PointLight(); + light.position.setArray(position instanceof Vector3 ? position.array : position); + + if (typeof color === 'string') { + color = colorUtil.parse(color); + } + range != null && (light.range = range); + color != null && (light.color = color); + intensity != null && (light.intensity = intensity); + + this._scene.add(light); + + return light; + } + + /** + * Create a ambient light. + * @param {Color} [color='#fff'] Color of ambient light. + * @param {number} [intensity=1] Intensity of ambient light. + */ + createAmbientLight(color: Color | string, intensity?: number) { + const light = new AmbientLight(); + + if (typeof color === 'string') { + color = colorUtil.parse(color)!; + } + color != null && (light.color = color); + intensity != null && (light.intensity = intensity); + + this._scene.add(light); + + return light; + } + + /** + * Create an cubemap ambient light and an spherical harmonic ambient light + * for specular and diffuse lighting in PBR rendering + * @param {ImageLike|TextureCube} [envImage] Panorama environment image, HDR format is better. Or a pre loaded texture cube + * @param {number} [specularIntenstity=0.7] Intensity of specular light. + * @param {number} [diffuseIntenstity=0.7] Intensity of diffuse light. + * @param {number} [exposure=1] Exposure of HDR image. Only if image in first paramter is HDR. + * @param {number} [prefilteredCubemapSize=32] The size of prefilerted cubemap. Larger value will take more time to do expensive prefiltering. + * @return {Promise} + */ + createAmbientCubemapLight( + envImage: TextureCube | string | TextureImageSource, + specIntensity?: number, + diffIntensity?: number, + exposure?: number, + prefilteredCubemapSize?: number + ) { + const self = this; + if (exposure == null) { + exposure = 0; + } + if (prefilteredCubemapSize == null) { + prefilteredCubemapSize = 32; + } + + const scene = this._scene; + + let loadPromise; + if ((envImage as TextureCube).textureType === 'textureCube') { + loadPromise = (envImage as TextureCube).isRenderable() + ? Promise.resolve(envImage) + : new Promise(function (resolve) { + (envImage as TextureCube).success(function () { + resolve(envImage); + }); + }); + } else { + loadPromise = this.loadTexture(envImage as string | TextureImageSource, { + exposure + }); + } + + return (loadPromise as Promise).then((envTexture) => { + const specLight = new AmbientCubemapLight({ + intensity: specIntensity != null ? specIntensity : 0.7 + }); + specLight.cubemap = envTexture; + envTexture.flipY = false; + // TODO Cache prefilter ? + specLight.prefilter(this._renderer, 32); + + const diffLight = new AmbientSHLight({ + intensity: diffIntensity != null ? diffIntensity : 0.7, + coefficients: shUtil.projectEnvironmentMap(this._renderer, specLight.cubemap, { + lod: 1 + }) + }); + scene.add(specLight); + scene.add(diffLight); + + return { + specular: specLight, + diffuse: diffLight, + // Original environment map + environmentMap: envTexture + }; + }); + } + + /** + * Load a [glTF](https://github.com/KhronosGroup/glTF) format model. + * You can convert FBX/DAE/OBJ format models to [glTF](https://github.com/KhronosGroup/glTF) with [fbx2gltf](https://github.com/pissang/claygl#fbx-to-gltf20-converter) python script, + * or simply using the [Clay Viewer](https://github.com/pissang/clay-viewer) client application. + * @param {string} url + * @param {Object} opts + * @param {string|clay.Shader} [opts.shader='clay.standard'] 'basic'|'lambert'|'standard'. + * @param {boolean} [opts.waitTextureLoaded=false] If add to scene util textures are all loaded. + * @param {boolean} [opts.autoPlayAnimation=true] If autoplay the animation of model. + * @param {boolean} [opts.upAxis='y'] Change model to y up if upAxis is 'z' + * @param {boolean} [opts.textureFlipY=false] + * @param {boolean} [opts.textureConvertToPOT=false] If convert texture to power-of-two + * @param {string} [opts.textureRootPath] Root path of texture. Default to be relative with glTF file. + * @param {clay.Node} [parentNode] Parent node that model will be mounted. Default to be scene + * @return {Promise} + */ + loadModel( + url: string, + opts?: { + shader?: Shader; + waitTextureLoaded?: boolean; + autoPlayAnimation?: boolean; + upAxis?: 'y' | 'z'; + textureFlipY?: boolean; + textureConvertToPOT?: boolean; + textureRootPath?: string; + }, + parentNode?: ClayNode + ) { + if (typeof url !== 'string') { + throw new Error('Invalid URL.'); + } + + opts = opts || {}; + const autoPlayAnimation = util.optional(opts.autoPlayAnimation, true); + + const shader = opts.shader || this._defaultShader; + + const loaderOpts = { + rootNode: new ClayNode(), + shader: shader, + textureRootPath: opts.textureRootPath, + crossOrigin: 'Anonymous', + textureFlipY: opts.textureFlipY, + textureConvertToPOT: opts.textureConvertToPOT + }; + if (opts.upAxis && opts.upAxis.toLowerCase() === 'z') { + loaderOpts.rootNode.rotation.identity().rotateX(-Math.PI / 2); + } + + parentNode = parentNode || this._scene; + const timeline = this._timeline; + + return new Promise((resolve, reject) => { + const afterLoad = (result: GLTFLoadResult) => { + if (this._disposed) { + return; + } + + parentNode!.add(result.rootNode!); + if (autoPlayAnimation) { + result.animators.forEach(function (animator) { + timeline.addAnimator(animator); + }); + } + resolve(result); + }; + + loadGLTF(url, loaderOpts) + .then((result) => { + if (this._disposed) { + return; + } + + if (!opts!.waitTextureLoaded) { + afterLoad(result); + } else { + Promise.all( + result.textures.map(function (texture) { + if (texture.isRenderable()) { + return Promise.resolve(texture); + } + return new Promise(function (resolve) { + texture.success(resolve); + texture.error(resolve); + }); + }) + ) + .then(function () { + afterLoad(result); + }) + .catch(function () { + afterLoad(result); + }); + } + }) + .catch(reject); + }); + } + + // TODO cloneModel + + /** + * Similar to `app.scene.cloneNode`, except it will mount the cloned node to the scene automatically. + * See more in {@link clay.Scene#cloneNode} + * + * @param node + * @param parentNode Parent node that new cloned node will be mounted. + * Default to have same parent with source node. + */ + cloneNode(node: ClayNode, parentNode?: ClayNode) { + parentNode = parentNode || node.getParent(); + + const newNode = this._scene.cloneNode(node); + if (parentNode) { + parentNode.add(newNode); + } + + return newNode; + } + // function App3D(dom, appNS) { + // appNS = appNS || {}; + + // Object.defineProperties(this, { + // /** + // * Container dom element + // * @name clay.application.App3D#container + // * @type {HTMLElement} + // */ + // container: { + // get: function () { + // return dom; + // } + // }, + // /** + // * @name clay.application.App3D#renderer + // * @type {clay.Renderer} + // */ + // renderer: { + // get: function () { + // return gRenderer; + // } + // }, + // /** + // * @name clay.application.App3D#scene + // * @type {clay.Renderer} + // */ + // scene: { + // get: function () { + // return gScene; + // } + // }, + // /** + // * @name clay.application.App3D#timeline + // * @type {clay.Renderer} + // */ + // timeline: { + // get: function () { + // return gTimeline; + // } + // }, + // /** + // * Time elapsed since last frame. Can be used in loop to calculate the movement. + // * @name clay.application.App3D#frameTime + // * @type {number} + // */ + // frameTime: { + // get: function () { + // return gFrameTime; + // } + // }, + // /** + // * Time elapsed since application created. + // * @name clay.application.App3D#elapsedTime + // * @type {number} + // */ + // elapsedTime: { + // get: function () { + // return gElapsedTime; + // } + // }, + + // /** + // * Width of viewport. + // * @name clay.application.App3D#width + // * @type {number} + // */ + // width: { + // get: function () { + // return gRenderer.getWidth(); + // } + // }, + // /** + // * Height of viewport. + // * @name clay.application.App3D#height + // * @type {number} + // */ + // height: { + // get: function () { + // return gRenderer.getHeight(); + // } + // } + // }); + + /** + * Resize the application. Will use the container clientWidth/clientHeight if width/height in parameters are not given. + */ + resize(width: number, height: number) { + const container = this._container; + this._renderer.resize(width || container.clientWidth, height || container.clientHeight); + } + + /** + * Dispose the application + * @function + */ + dispose() { + this._disposed = true; + + this._timeline.stop(); + this._renderer.disposeScene(this._scene); + this._shadowPass && this._shadowPass.dispose(this._renderer); + this._eventManager && this._eventManager.dispose(); + + this._container.innerHTML = ''; + } +} + +function isImageLikeElement(val: any) { + return ( + (typeof Image !== 'undefined' && val instanceof Image) || + (typeof HTMLCanvasElement !== 'undefined' && val instanceof HTMLCanvasElement) || + (typeof HTMLVideoElement !== 'undefined' && val instanceof HTMLVideoElement) + ); +} + +function getKeyFromImageLike(val: any) { + return typeof val === 'string' ? val : val.__key__ || (val.__key__ = util.genGUID()); +} + +export default App3D; diff --git a/src/GeometryBase.ts b/src/GeometryBase.ts index 69d9822e..d8ef1ebd 100644 --- a/src/GeometryBase.ts +++ b/src/GeometryBase.ts @@ -9,6 +9,7 @@ import type Camera from './Camera'; import type Renderable from './Renderable'; import type Vector2 from './math/Vector2'; import type BoundingBox from './math/BoundingBox'; +import type { Intersection } from './picking/RayPicking'; export type AttributeType = 'byte' | 'ubyte' | 'short' | 'ushort' | 'float'; export type AttributeSize = 1 | 2 | 3 | 4; @@ -324,7 +325,7 @@ class GeometryBase { renderer: Renderer, camera: Camera, renderable: Renderable, - out: Vector2 + out: Intersection[] ) => boolean; /** @@ -335,7 +336,7 @@ class GeometryBase { * ``` * @type {?Function} */ - pickByRay?: (ray: Ray, renderable: Renderable, out: Vector2) => boolean; + pickByRay?: (ray: Ray, renderable: Renderable, out: Intersection[]) => boolean; protected _cache = new ClayCache(); private _attributeList: string[]; diff --git a/src/Material.ts b/src/Material.ts index 9e6f4952..69804808 100644 --- a/src/Material.ts +++ b/src/Material.ts @@ -1,6 +1,10 @@ import * as util from './core/util'; import * as colorUtil from './core/color'; import Shader, { ShaderDefineValue, ShaderPrecision, ShaderType, ShaderUniform } from './Shader'; +import Texture from './Texture'; + +type MaterialUniformValue = number | string | ArrayLike | Texture; + const parseColor = colorUtil.parseToFloat; const programKeyCache: Record = {}; @@ -150,7 +154,7 @@ class Material { * @param {string} symbol * @param {number|array|clay.Texture} value */ - setUniform(symbol: string, value: number | string | ArrayLike) { + setUniform(symbol: string, value: MaterialUniformValue) { if (value === undefined) { return; // console.warn('Uniform value "' + symbol + '" is undefined'); diff --git a/src/Node.ts b/src/Node.ts index b43a46d8..69f6892d 100644 --- a/src/Node.ts +++ b/src/Node.ts @@ -55,6 +55,11 @@ export interface ClayNodeOpts { * If node and its chilren invisible */ invisible?: boolean; + + /** + * If not trigger event. Available in the App3D + */ + silent?: boolean; } interface ClayNode extends ClayNodeOpts { diff --git a/src/Renderer.ts b/src/Renderer.ts index 18448579..46959f7f 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -195,7 +195,7 @@ interface Viewport { height: number; devicePixelRatio: number; } -interface RendererOpts { +export interface RendererOpts { canvas: HTMLCanvasElement | null; /** diff --git a/src/Scene.ts b/src/Scene.ts index d93608c2..cacd4995 100644 --- a/src/Scene.ts +++ b/src/Scene.ts @@ -64,7 +64,7 @@ function setUniforms( } } -class RenderList { +export class RenderList { opaque: Renderable[] = []; transparent: Renderable[] = []; @@ -113,6 +113,8 @@ class Scene extends ClayNode { // Uniforms for shadow map. shadowUniforms: Record = {}; + skybox?: Skybox; + private _cameraList: Camera[] = []; // Properties to save the light information in the scene @@ -230,8 +232,6 @@ class Scene extends ClayNode { * Clone a node and it's children, including mesh, camera, light, etc. * Unlike using `Node#clone`. It will clone skeleton and remap the joints. Material will also be cloned. * - * @param {clay.Node} node - * @return {clay.Node} */ cloneNode(node: ClayNode) { const newNode = node.clone(); diff --git a/src/app/EventManager.ts b/src/app/EventManager.ts new file mode 100644 index 00000000..d51814d6 --- /dev/null +++ b/src/app/EventManager.ts @@ -0,0 +1,201 @@ +import * as rayPicking from '../picking/rayPicking'; +import vendor from '../core/vendor'; +import type Renderer from '../Renderer'; +import type ClayNode from '../Node'; +import Scene from '../Scene'; + +// TODO Use pointer event +const EVENT_NAMES = [ + 'click', + 'dblclick', + 'mouseover', + 'mouseout', + 'mousemove', + 'mousedown', + 'mouseup', + 'touchstart', + 'touchend', + 'touchmove', + 'mousewheel' +] as const; + +type ClayEventType = + | 'click' + | 'dblclick' + | 'mousewheel' + | 'pointerdown' + | 'pointermove' + | 'pointerup' + | 'pointerover' + | 'pointerout'; + +// TODO only mouseout event only have target property from Itersection +export interface ClayMouseEvent extends Partial { + target: ClayNode; + type: ClayEventType; + offsetX: number; + offsetY: number; + wheelDelta?: number; + cancelBubble?: boolean; +} + +function packageEvent( + eventType: ClayEventType, + pickResult: Partial, + offsetX: number, + offsetY: number, + wheelDelta?: number +) { + return Object.assign( + { + type: eventType, + offsetX: offsetX, + offsetY: offsetY, + wheelDelta: wheelDelta + }, + pickResult + ) as ClayMouseEvent; +} + +function bubblingEvent(target: ClayNode | undefined, event: ClayMouseEvent) { + while (target && !event.cancelBubble) { + target.trigger(event.type, event); + target = target.getParent(); + } +} + +function makeHandlerName(eveType: string) { + return '_' + eveType + 'Handler'; +} + +export class EventManager { + private _renderer: Renderer; + private _container: HTMLElement; + private _scene: Scene; + constructor(container: HTMLElement, renderer: Renderer, scene: Scene) { + this._container = container; + this._renderer = renderer; + this._scene = scene; + + this.init(); + } + + init() { + const dom = this._container; + const scene = this._scene; + const renderer = this._renderer; + const mainCamera = scene.getMainCamera(); + + let oldTarget: ClayNode | undefined; + EVENT_NAMES.forEach((domEveType) => { + vendor.addEventListener( + dom, + domEveType, + ((this as any)[makeHandlerName(domEveType)] = (e: MouseEvent | TouchEvent) => { + if (!mainCamera) { + // Not have camera yet. + return; + } + e.preventDefault && e.preventDefault(); + + const box = dom.getBoundingClientRect(); + let offsetX, offsetY; + let eveType: ClayEventType; + + if (domEveType.indexOf('touch') >= 0) { + const touch = + domEveType !== 'touchend' + ? (e as TouchEvent).targetTouches[0] + : (e as TouchEvent).changedTouches[0]; + + offsetX = touch.clientX - box.left; + offsetY = touch.clientY - box.top; + } else { + offsetX = (e as MouseEvent).clientX - box.left; + offsetY = (e as MouseEvent).clientY - box.top; + } + + const pickResult = rayPicking.pick(renderer, scene, mainCamera, offsetX, offsetY); + + let delta; + if (domEveType === 'mousewheel') { + delta = (e as any).wheelDelta ? (e as any).wheelDelta / 120 : -(e.detail || 0) / 3; + } + + if (pickResult) { + // Just ignore silent element. + if (pickResult.target.silent) { + return; + } + + if (domEveType === 'mousemove' || domEveType === 'touchmove') { + // PENDING touchdown should trigger mouseover event ? + const targetChanged = pickResult.target !== oldTarget; + if (targetChanged) { + oldTarget && + bubblingEvent( + oldTarget, + packageEvent( + 'pointerout', + { + target: oldTarget + }, + offsetX, + offsetY + ) + ); + } + bubblingEvent( + pickResult.target, + packageEvent('pointermove', pickResult, offsetX, offsetY) + ); + if (targetChanged) { + bubblingEvent( + pickResult.target, + packageEvent('pointerover', pickResult, offsetX, offsetY) + ); + } + } else { + // Map events + eveType = + domEveType === 'mousedown' || domEveType === 'touchstart' + ? 'pointerdown' + : domEveType === 'mouseup' || domEveType === 'touchend' + ? 'pointerup' + : domEveType === 'mouseover' + ? 'pointerover' + : domEveType === 'mouseout' + ? 'pointerout' + : domEveType; + bubblingEvent( + pickResult.target, + packageEvent(eveType, pickResult, offsetX, offsetY, delta) + ); + } + oldTarget = pickResult.target; + } else if (oldTarget) { + bubblingEvent( + oldTarget, + packageEvent( + 'pointerout', + { + target: oldTarget + }, + offsetX, + offsetY + ) + ); + oldTarget = undefined; + } + }) + ); + }); + } + + dispose() { + EVENT_NAMES.forEach((eveType) => { + const handler = (this as any)[makeHandlerName(eveType)]; + handler && vendor.removeEventListener(this._container, eveType, handler); + }); + } +} diff --git a/src/app/GPUResourceManager.ts b/src/app/GPUResourceManager.ts new file mode 100644 index 00000000..b85984e3 --- /dev/null +++ b/src/app/GPUResourceManager.ts @@ -0,0 +1,111 @@ +import type Geometry from '../Geometry'; +import type AmbientCubemap from '../light/AmbientCubemap'; +import type Material from '../Material'; +import type Renderer from '../Renderer'; +import type Scene from '../Scene'; +import type Texture from '../Texture'; + +type Resource = Geometry | Texture; +const usedMap = new WeakMap(); + +function markUnused(resourceList: Resource[]) { + for (let i = 0; i < resourceList.length; i++) { + usedMap.set(resourceList[i], 0); + } +} + +function checkAndDispose(renderer: Renderer, resourceList: Resource[]) { + for (let i = 0; i < resourceList.length; i++) { + if (!usedMap.get(resourceList[i])) { + resourceList[i].dispose(renderer); + } + } +} + +function updateUsed(resource: Resource, list: Resource[]) { + const used = (usedMap.get(resource) || 0) + 1; + usedMap.set(resource, used); + if (used === 1) { + // Don't push to the list twice. + list.push(resource); + } +} +function collectResources( + scene: Scene, + textureResourceList: Texture[], + geometryResourceList: Geometry[] +) { + let prevMaterial: Material; + let prevGeometry: Geometry; + scene.traverse(function (renderable) { + if (renderable.isRenderable()) { + const geometry = renderable.geometry; + const material = renderable.material; + + // TODO optimize!! + if (material !== prevMaterial) { + const textureUniforms = material.getTextureUniforms(); + for (let u = 0; u < textureUniforms.length; u++) { + const uniformName = textureUniforms[u]; + const val = material.uniforms[uniformName].value; + const uniformType = material.uniforms[uniformName].type; + if (!val) { + continue; + } + if (uniformType === 't') { + updateUsed(val, textureResourceList); + } else if (uniformType === 'tv') { + for (let k = 0; k < val.length; k++) { + if (val[k]) { + updateUsed(val[k], textureResourceList); + } + } + } + } + } + if (geometry !== prevGeometry) { + updateUsed(geometry, geometryResourceList); + } + + prevMaterial = material; + prevGeometry = geometry; + } + }); + + for (let k = 0; k < scene.lights.length; k++) { + const cubemap = (scene.lights[k] as AmbientCubemap).cubemap; + // Track AmbientCubemap + cubemap && updateUsed(cubemap, textureResourceList); + } +} + +export default class GPUResourceManager { + private _renderer: Renderer; + private _texturesList: Texture[] = []; + private _geometriesList: Geometry[] = []; + + constructor(renderer: Renderer) { + this._renderer = renderer; + } + + collect(scene: Scene) { + const renderer = this._renderer; + const texturesList = this._texturesList; + const geometriesList = this._geometriesList; + // Mark all resources unused; + markUnused(texturesList); + markUnused(geometriesList); + + // Collect resources used in this frame. + const newTexturesList: Texture[] = []; + const newGeometriesList: Geometry[] = []; + collectResources(scene, newTexturesList, newGeometriesList); + + // Dispose those unsed resources. + checkAndDispose(renderer, texturesList); + checkAndDispose(renderer, geometriesList); + + this._texturesList = newTexturesList; + this._geometriesList = newGeometriesList; + } +} diff --git a/src/application.ts b/src/application.ts index cc167b7a..b2c61c92 100644 --- a/src/application.ts +++ b/src/application.ts @@ -4,61 +4,6 @@ * @namespace clay.application */ -// TODO createCompositor -// TODO Dispose test. geoCache test. -// TODO Tonemapping exposure -// TODO fitModel. -// TODO Particle ? -import Renderer from './Renderer'; -import Scene from './Scene'; -import Timeline from './Timeline'; -import CubeGeo from './geometry/Cube'; -import SphereGeo from './geometry/Sphere'; -import PlaneGeo from './geometry/Plane'; -import ParametricSurfaceGeo from './geometry/ParametricSurface'; -import Texture2D from './Texture2D'; -import TextureCube from './TextureCube'; -import Texture from './Texture'; -import Mesh from './Mesh'; -import Material from './Material'; -import PerspectiveCamera from './camera/Perspective'; -import OrthographicCamera from './camera/Orthographic'; -import Vector3 from './math/Vector3'; -import GLTFLoader from './loader/GLTF'; -import Node from './Node'; -import DirectionalLight from './light/Directional'; -import PointLight from './light/Point'; -import SpotLight from './light/Spot'; -import AmbientLight from './light/Ambient'; -import AmbientCubemapLight from './light/AmbientCubemap'; -import AmbientSHLight from './light/AmbientSH'; -import ShadowMapPass from './prePass/ShadowMap'; -import RayPicking from './picking/RayPicking'; -import LRUCache from './core/LRU'; -import * as util from './core/util'; -import shUtil from './util/sh'; -import textureUtil from './util/texture'; -import vendor from './core/vendor'; - -import * as colorUtil from './core/color'; -const parseColor = colorUtil.parseToFloat; - -import shaderLibrary from './shader/builtin'; -import Shader from './Shader'; - -const EVE_NAMES = [ - 'click', - 'dblclick', - 'mouseover', - 'mouseout', - 'mousemove', - 'touchstart', - 'touchend', - 'touchmove', - 'mousewheel', - 'DOMMouseScroll' -]; - /** * @typedef {string|HTMLCanvasElement|HTMLImageElement|HTMLVideoElement} ImageLike */ @@ -120,1353 +65,6 @@ const EVE_NAMES = [ * @property {TextureLike} [emissiveMap] */ -/** - * Using App3D is a much more convenient way to create and manage your 3D application. - * - * It provides the abilities to: - * - * + Manage application loop and rendering. - * + Collect GPU resource automatically without memory leak concern. - * + Mouse event management. - * + Create scene objects, materials, textures with simpler code. - * + Load models with one line of code. - * + Promised interfaces. - * - * Here is a basic example to create a rotating cube. - * -```js -const app = clay.application.create('#viewport', { - init: function (app) { - // Create a perspective camera. - // First parameter is the camera position. Which is in front of the cube. - // Second parameter is the camera lookAt target. Which is the origin of the world, and where the cube puts. - this._camera = app.createCamera([0, 2, 5], [0, 0, 0]); - // Create a sample cube - this._cube = app.createCube(); - // Create a directional light. The direction is from top right to left bottom, away from camera. - this._mainLight = app.createDirectionalLight([-1, -1, -1]); - }, - loop: function (app) { - // Simply rotating the cube every frame. - this._cube.rotation.rotateY(app.frameTime / 1000); - } -}); -``` - * @constructor - * @alias clay.application.App3D - * @param {DomQuery} dom Container dom element or a selector string that can be used in `querySelector` - * @param {App3DNamespace} appNS Options and namespace used in creating app3D - */ -function App3D(dom, appNS) { - appNS = appNS || {}; - appNS.graphic = appNS.graphic || {}; - - appNS.glAttributes = appNS.glAttributes || {}; - - if (appNS.autoRender == null) { - appNS.autoRender = true; - } - - if (typeof dom === 'string') { - dom = window.document.querySelector(dom); - } - - if (!dom) { - throw new Error('Invalid dom'); - } - - const isDomCanvas = - !dom.nodeName || // Not in dom environment - dom.nodeName.toUpperCase() === 'CANVAS'; - - const rendererOpts = {}; - isDomCanvas && (rendererOpts.canvas = dom); - appNS.devicePixelRatio && (rendererOpts.devicePixelRatio = appNS.devicePixelRatio); - - ['alpha', 'depth', 'stencil', 'antialias', 'premultipliedAlpha', 'preserveDrawingBuffer'].forEach( - function (attrName) { - if (appNS.glAttributes[attrName] != null) { - rendererOpts[attrName] = appNS.glAttributes[attrName]; - } - } - ); - - const gRenderer = new Renderer(rendererOpts); - const gWidth = appNS.width || dom.clientWidth; - const gHeight = appNS.height || dom.clientHeight; - - const gScene = new Scene(); - const gTimeline = new Timeline(); - const gShadowPass = appNS.graphic.shadow && new ShadowMapPass(); - const gRayPicking = - appNS.event && - new RayPicking({ - scene: gScene, - renderer: gRenderer - }); - - !isDomCanvas && dom.appendChild(gRenderer.canvas); - - gRenderer.resize(gWidth, gHeight); - - let gFrameTime = 0; - let gElapsedTime = 0; - - gTimeline.start(); - - const userMethods = {}; - for (const key in appNS.methods) { - userMethods[key] = appNS.methods[key].bind(appNS, this); - } - - Object.defineProperties(this, { - /** - * Container dom element - * @name clay.application.App3D#container - * @type {HTMLElement} - */ - container: { - get: function () { - return dom; - } - }, - /** - * @name clay.application.App3D#renderer - * @type {clay.Renderer} - */ - renderer: { - get: function () { - return gRenderer; - } - }, - /** - * @name clay.application.App3D#scene - * @type {clay.Renderer} - */ - scene: { - get: function () { - return gScene; - } - }, - /** - * @name clay.application.App3D#timeline - * @type {clay.Renderer} - */ - timeline: { - get: function () { - return gTimeline; - } - }, - /** - * Time elapsed since last frame. Can be used in loop to calculate the movement. - * @name clay.application.App3D#frameTime - * @type {number} - */ - frameTime: { - get: function () { - return gFrameTime; - } - }, - /** - * Time elapsed since application created. - * @name clay.application.App3D#elapsedTime - * @type {number} - */ - elapsedTime: { - get: function () { - return gElapsedTime; - } - }, - - /** - * Width of viewport. - * @name clay.application.App3D#width - * @type {number} - */ - width: { - get: function () { - return gRenderer.getWidth(); - } - }, - /** - * Height of viewport. - * @name clay.application.App3D#height - * @type {number} - */ - height: { - get: function () { - return gRenderer.getHeight(); - } - }, - - /** - * Methods from {@link clay.application.create} - * @name clay.application.App3D#methods - * @type {number} - */ - methods: { - get: function () { - return userMethods; - } - }, - - _shadowPass: { - get: function () { - return gShadowPass; - } - }, - - _appNS: { - get: function () { - return appNS; - } - } - }); - - /** - * Resize the application. Will use the container clientWidth/clientHeight if width/height in parameters are not given. - * @function - * @memberOf {clay.application.App3D} - * @param {number} [width] - * @param {number} [height] - */ - this.resize = function (width, height) { - const gWidth = width || appNS.width || dom.clientWidth; - const gHeight = height || appNS.height || dom.clientHeight; - gRenderer.resize(gWidth, gHeight); - }; - - /** - * Dispose the application - * @function - */ - this.dispose = function () { - this._disposed = true; - - if (appNS.dispose) { - appNS.dispose(this); - } - gTimeline.stop(); - gRenderer.disposeScene(gScene); - gShadowPass && gShadowPass.dispose(gRenderer); - - dom.innerHTML = ''; - EVE_NAMES.forEach(function (eveType) { - this[makeHandlerName(eveType)] && vendor.removeEventListener(dom, makeHandlerName(eveType)); - }, this); - }; - - gRayPicking && this._initMouseEvents(gRayPicking); - - this._geoCache = new LRUCache(20); - this._texCache = new LRUCache(20); - - // GPU Resources. - this._texturesList = {}; - this._geometriesList = {}; - - // Do init the application. - const initPromise = Promise.resolve(appNS.init && appNS.init(this)); - // Use the inited camera. - gRayPicking && (gRayPicking.camera = gScene.getMainCamera()); - - if (!appNS.loop) { - console.warn('Miss loop method.'); - } - - const self = this; - initPromise.then(function () { - gTimeline.on( - 'frame', - function (frameTime) { - gFrameTime = frameTime; - gElapsedTime += frameTime; - - const camera = gScene.getMainCamera(); - if (camera) { - camera.aspect = gRenderer.getViewportAspect(); - } - gRayPicking && (gRayPicking.camera = camera); - - appNS.loop && appNS.loop(self); - - if (appNS.autoRender) { - self.render(); - } - - self.collectResources(); - }, - this - ); - }); - - gScene.on( - 'beforerender', - function (renderer, scene, camera, renderList) { - if (this._inRender) { - // Only update graphic options when using #render function. - this._updateGraphicOptions(appNS.graphic, renderList.opaque, false); - this._updateGraphicOptions(appNS.graphic, renderList.transparent, false); - } - }, - this - ); -} - -function isImageLikeElement(val) { - return ( - (typeof Image !== 'undefined' && val instanceof Image) || - (typeof HTMLCanvasElement !== 'undefined' && val instanceof HTMLCanvasElement) || - (typeof HTMLVideoElement !== 'undefined' && val instanceof HTMLVideoElement) - ); -} - -function getKeyFromImageLike(val) { - return typeof val === 'string' ? val : val.__key__ || (val.__key__ = util.genGUID()); -} - -function makeHandlerName(eveType) { - return '_' + eveType + 'Handler'; -} - -function packageEvent(eventType, pickResult, offsetX, offsetY, wheelDelta) { - const event = util.clone(pickResult); - event.type = eventType; - event.offsetX = offsetX; - event.offsetY = offsetY; - if (wheelDelta !== null) { - event.wheelDelta = wheelDelta; - } - return event; -} - -function bubblingEvent(target, event) { - while (target && !event.cancelBubble) { - target.trigger(event.type, event); - target = target.getParent(); - } -} - -App3D.prototype._initMouseEvents = function (rayPicking) { - const dom = this.container; - - let oldTarget = null; - EVE_NAMES.forEach(function (_eveType) { - vendor.addEventListener( - dom, - _eveType, - (this[makeHandlerName(_eveType)] = function (e) { - if (!rayPicking.camera) { - // Not have camera yet. - return; - } - e.preventDefault && e.preventDefault(); - - const box = dom.getBoundingClientRect(); - let offsetX, offsetY; - let eveType = _eveType; - - if (eveType.indexOf('touch') >= 0) { - const touch = eveType !== 'touchend' ? e.targetTouches[0] : e.changedTouches[0]; - if (eveType === 'touchstart') { - eveType = 'mousedown'; - } else if (eveType === 'touchend') { - eveType = 'mouseup'; - } else if (eveType === 'touchmove') { - eveType = 'mousemove'; - } - offsetX = touch.clientX - box.left; - offsetY = touch.clientY - box.top; - } else { - offsetX = e.clientX - box.left; - offsetY = e.clientY - box.top; - } - - const pickResult = rayPicking.pick(offsetX, offsetY); - - let delta; - if (eveType === 'DOMMouseScroll' || eveType === 'mousewheel') { - delta = e.wheelDelta ? e.wheelDelta / 120 : -(e.detail || 0) / 3; - } - - if (pickResult) { - // Just ignore silent element. - if (pickResult.target.silent) { - return; - } - - if (eveType === 'mousemove') { - // PENDING touchdown should trigger mouseover event ? - const targetChanged = pickResult.target !== oldTarget; - if (targetChanged) { - oldTarget && - bubblingEvent( - oldTarget, - packageEvent( - 'mouseout', - { - target: oldTarget - }, - offsetX, - offsetY - ) - ); - } - bubblingEvent( - pickResult.target, - packageEvent('mousemove', pickResult, offsetX, offsetY) - ); - if (targetChanged) { - bubblingEvent( - pickResult.target, - packageEvent('mouseover', pickResult, offsetX, offsetY) - ); - } - } else { - bubblingEvent( - pickResult.target, - packageEvent(eveType, pickResult, offsetX, offsetY, delta) - ); - } - oldTarget = pickResult.target; - } else if (oldTarget) { - bubblingEvent( - oldTarget, - packageEvent( - 'mouseout', - { - target: oldTarget - }, - offsetX, - offsetY - ) - ); - oldTarget = null; - } - }) - ); - }, this); -}; - -App3D.prototype._updateGraphicOptions = function (graphicOpts, list, isSkybox) { - const enableTonemapping = !!graphicOpts.tonemapping; - const isLinearSpace = !!graphicOpts.linear; - - let prevMaterial; - - for (let i = 0; i < list.length; i++) { - const mat = list[i].material; - if (mat === prevMaterial) { - continue; - } - - enableTonemapping - ? mat.define('fragment', 'TONEMAPPING') - : mat.undefine('fragment', 'TONEMAPPING'); - if (isLinearSpace) { - let decodeSRGB = true; - if (isSkybox && mat.get('environmentMap') && !mat.get('environmentMap').sRGB) { - decodeSRGB = false; - } - decodeSRGB && mat.define('fragment', 'SRGB_DECODE'); - mat.define('fragment', 'SRGB_ENCODE'); - } else { - mat.undefine('fragment', 'SRGB_DECODE'); - mat.undefine('fragment', 'SRGB_ENCODE'); - } - - prevMaterial = mat; - } -}; - -App3D.prototype._doRender = function (renderer, scene) { - const camera = scene.getMainCamera(); - renderer.render(scene, camera, true); -}; - -/** - * Do render - */ -App3D.prototype.render = function () { - this._inRender = true; - const appNS = this._appNS; - appNS.beforeRender && appNS.beforeRender(this); - - const scene = this.scene; - const renderer = this.renderer; - const shadowPass = this._shadowPass; - - scene.update(); - const skyboxList = []; - scene.skybox && skyboxList.push(scene.skybox); - scene.skydome && skyboxList.push(scene.skydome); - - this._updateGraphicOptions(appNS.graphic, skyboxList, true); - // Render shadow pass - shadowPass && shadowPass.render(renderer, scene, null, true); - - this._doRender(renderer, scene, true); - - appNS.afterRender && appNS.afterRender(this); - this._inRender = false; -}; - -App3D.prototype.collectResources = function () { - const renderer = this.renderer; - const scene = this.scene; - const texturesList = this._texturesList; - const geometriesList = this._geometriesList; - // Mark all resources unused; - markUnused(texturesList); - markUnused(geometriesList); - - // Collect resources used in this frame. - const newTexturesList = []; - const newGeometriesList = []; - collectResources(scene, newTexturesList, newGeometriesList); - - // Dispose those unsed resources. - checkAndDispose(renderer, texturesList); - checkAndDispose(renderer, geometriesList); - - this._texturesList = newTexturesList; - this._geometriesList = newGeometriesList; -}; - -function markUnused(resourceList) { - for (let i = 0; i < resourceList.length; i++) { - resourceList[i].__used = 0; - } -} - -function checkAndDispose(renderer, resourceList) { - for (let i = 0; i < resourceList.length; i++) { - if (!resourceList[i].__used) { - resourceList[i].dispose(renderer); - } - } -} - -function updateUsed(resource, list) { - resource.__used = resource.__used || 0; - resource.__used++; - if (resource.__used === 1) { - // Don't push to the list twice. - list.push(resource); - } -} -function collectResources(scene, textureResourceList, geometryResourceList) { - let prevMaterial; - let prevGeometry; - scene.traverse(function (renderable) { - if (renderable.isRenderable()) { - const geometry = renderable.geometry; - const material = renderable.material; - - // TODO optimize!! - if (material !== prevMaterial) { - const textureUniforms = material.getTextureUniforms(); - for (let u = 0; u < textureUniforms.length; u++) { - const uniformName = textureUniforms[u]; - const val = material.uniforms[uniformName].value; - const uniformType = material.uniforms[uniformName].type; - if (!val) { - continue; - } - if (uniformType === 't') { - updateUsed(val, textureResourceList); - } else if (uniformType === 'tv') { - for (let k = 0; k < val.length; k++) { - if (val[k]) { - updateUsed(val[k], textureResourceList); - } - } - } - } - } - if (geometry !== prevGeometry) { - updateUsed(geometry, geometryResourceList); - } - - prevMaterial = material; - prevGeometry = geometry; - } - }); - - for (let k = 0; k < scene.lights.length; k++) { - // Track AmbientCubemap - if (scene.lights[k].cubemap) { - updateUsed(scene.lights[k].cubemap, textureResourceList); - } - } -} -/** - * Load a texture from image or string. - * @param {ImageLike} img - * @param {Object} [opts] Texture options. - * @param {boolean} [opts.flipY=true] If flipY. See {@link clay.Texture.flipY} - * @param {boolean} [opts.convertToPOT=false] Force convert None Power of Two texture to Power of two so it can be tiled. - * @param {number} [opts.anisotropic] Anisotropic filtering. See {@link clay.Texture.anisotropic} - * @param {number} [opts.wrapS=clay.Texture.REPEAT] See {@link clay.Texture.wrapS} - * @param {number} [opts.wrapT=clay.Texture.REPEAT] See {@link clay.Texture.wrapT} - * @param {number} [opts.minFilter=clay.Texture.LINEAR_MIPMAP_LINEAR] See {@link clay.Texture.minFilter} - * @param {number} [opts.magFilter=clay.Texture.LINEAR] See {@link clay.Texture.magFilter} - * @param {number} [opts.exposure] Only be used when source is a HDR image. - * @param {boolean} [useCache] If use cache. - * @return {Promise} - * @example - * app.loadTexture('diffuseMap.jpg') - * .then(function (texture) { - * material.set('diffuseMap', texture); - * }); - */ -App3D.prototype.loadTexture = function (urlOrImg, opts, useCache) { - const self = this; - const key = getKeyFromImageLike(urlOrImg); - if (useCache) { - if (this._texCache.get(key)) { - return this._texCache.get(key); - } - } - // TODO Promise ? - const promise = new Promise(function (resolve, reject) { - const texture = self.loadTextureSync(urlOrImg, opts); - if (!texture.isRenderable()) { - texture.success(function () { - if (self._disposed) { - return; - } - resolve(texture); - }); - texture.error(function () { - if (self._disposed) { - return; - } - reject(); - }); - } else { - resolve(texture); - } - }); - if (useCache) { - this._texCache.put(key, promise); - } - return promise; -}; - -/** - * Create a texture from image or string synchronously. Texture can be use directly and don't have to wait for it's loaded. - * @param {ImageLike} img - * @param {Object} [opts] Texture options. - * @param {boolean} [opts.flipY=true] If flipY. See {@link clay.Texture.flipY} - * @param {boolean} [opts.convertToPOT=false] Force convert None Power of Two texture to Power of two so it can be tiled. - * @param {number} [opts.anisotropic] Anisotropic filtering. See {@link clay.Texture.anisotropic} - * @param {number} [opts.wrapS=clay.Texture.REPEAT] See {@link clay.Texture.wrapS} - * @param {number} [opts.wrapT=clay.Texture.REPEAT] See {@link clay.Texture.wrapT} - * @param {number} [opts.minFilter=clay.Texture.LINEAR_MIPMAP_LINEAR] See {@link clay.Texture.minFilter} - * @param {number} [opts.magFilter=clay.Texture.LINEAR] See {@link clay.Texture.magFilter} - * @param {number} [opts.exposure] Only be used when source is a HDR image. - * @return {clay.Texture2D} - * @example - * const texture = app.loadTexture('diffuseMap.jpg', { - * anisotropic: 8, - * flipY: false - * }); - * material.set('diffuseMap', texture); - */ -App3D.prototype.loadTextureSync = function (urlOrImg, opts) { - let texture = new Texture2D(opts); - if (typeof urlOrImg === 'string') { - if (urlOrImg.match(/.hdr$|^data:application\/octet-stream/)) { - texture = textureUtil.loadTexture( - urlOrImg, - { - exposure: opts && opts.exposure, - fileType: 'hdr' - }, - function () { - texture.dirty(); - texture.trigger('success'); - } - ); - for (const key in opts) { - texture[key] = opts[key]; - } - } else { - texture.load(urlOrImg); - } - } else if (isImageLikeElement(urlOrImg)) { - texture.image = urlOrImg; - texture.dynamic = urlOrImg instanceof HTMLVideoElement; - } - return texture; -}; - -/** - * Create a texture from image or string synchronously. Texture can be use directly and don't have to wait for it's loaded. - * @param {ImageLike} img - * @param {Object} [opts] Texture options. - * @param {boolean} [opts.flipY=false] If flipY. See {@link clay.Texture.flipY} - * @return {Promise} - * @example - * app.loadTextureCube({ - * px: 'skybox/px.jpg', py: 'skybox/py.jpg', pz: 'skybox/pz.jpg', - * nx: 'skybox/nx.jpg', ny: 'skybox/ny.jpg', nz: 'skybox/nz.jpg' - * }).then(function (texture) { - * skybox.setEnvironmentMap(texture); - * }) - */ -App3D.prototype.loadTextureCube = function (imgList, opts) { - const textureCube = this.loadTextureCubeSync(imgList, opts); - return new Promise(function (resolve, reject) { - if (textureCube.isRenderable()) { - resolve(textureCube); - } else { - textureCube - .success(function () { - resolve(textureCube); - }) - .error(function () { - reject(); - }); - } - }); -}; - -/** - * Create a texture from image or string synchronously. Texture can be use directly and don't have to wait for it's loaded. - * @param {ImageLike} img - * @param {Object} [opts] Texture options. - * @param {boolean} [opts.flipY=false] If flipY. See {@link clay.Texture.flipY} - * @return {clay.TextureCube} - * @example - * const texture = app.loadTextureCubeSync({ - * px: 'skybox/px.jpg', py: 'skybox/py.jpg', pz: 'skybox/pz.jpg', - * nx: 'skybox/nx.jpg', ny: 'skybox/ny.jpg', nz: 'skybox/nz.jpg' - * }); - * skybox.setEnvironmentMap(texture); - */ -App3D.prototype.loadTextureCubeSync = function (imgList, opts) { - opts = opts || {}; - opts.flipY = opts.flipY || false; - const textureCube = new TextureCube(opts); - if ( - !imgList || - !imgList.px || - !imgList.nx || - !imgList.py || - !imgList.ny || - !imgList.pz || - !imgList.nz - ) { - throw new Error('Invalid cubemap format. Should be an object including px,nx,py,ny,pz,nz'); - } - if (typeof imgList.px === 'string') { - textureCube.load(imgList); - } else { - textureCube.image = util.clone(imgList); - } - return textureCube; -}; - -/** - * Create a material. - * @param {Object|StandardMaterialMRConfig} materialConfig. materialConfig contains `shader`, `transparent` and uniforms that used in corresponding uniforms. - * Uniforms can be `color`, `alpha` `diffuseMap` etc. - * @param {string|clay.Shader} [shader='clay.standardMR'] Default to be standard shader with metalness and roughness workflow. - * @param {boolean} [transparent=false] If material is transparent. - * @param {boolean} [textureConvertToPOT=false] Force convert None Power of Two texture to Power of two so it can be tiled. - * @param {boolean} [textureFlipY=true] If flip y of texture. - * @param {Function} [textureLoaded] Callback when single texture loaded. - * @param {Function} [texturesReady] Callback when all texture loaded. - * @return {clay.Material} - */ -App3D.prototype.createMaterial = function (matConfig) { - matConfig = matConfig || {}; - matConfig.shader = matConfig.shader || 'clay.standardMR'; - const shader = - matConfig.shader instanceof Shader ? matConfig.shader : shaderLibrary.get(matConfig.shader); - const material = new Material({ - shader: shader - }); - if (matConfig.name) { - material.name = matConfig.name; - } - - const texturesLoading = []; - function makeTextureSetter(key) { - return function (texture) { - material.setUniform(key, texture); - matConfig.textureLoaded && matConfig.textureLoaded(key, texture); - return texture; - }; - } - for (const key in matConfig) { - if (material.uniforms[key]) { - const val = matConfig[key]; - if ( - (material.uniforms[key].type === 't' || isImageLikeElement(val)) && - !(val instanceof Texture) - ) { - // Try to load a texture. - texturesLoading.push( - this.loadTexture(val, { - convertToPOT: matConfig.textureConvertToPOT || false, - flipY: matConfig.textureFlipY == null ? true : matConfig.textureFlipY - }).then(makeTextureSetter(key)) - ); - } else { - material.setUniform(key, val); - } - } - } - - if (matConfig.texturesReady) { - Promise.all(texturesLoading).then(function (textures) { - matConfig.texturesReady(textures); - }); - } - if (matConfig.transparent) { - material.depthTest = false; - material.transparent = true; - } - - return material; -}; - -/** - * Create a cube mesh and add it to the scene or the given parent node. - * @function - * @param {Object|clay.Material} [material] - * @param {clay.Node} [parentNode] Parent node to append. Default to be scene. - * @param {Array.|number} [subdivision=1] Subdivision of cube. - * Can be a number to represent both width, height and depth dimensions. Or an array to represent them respectively. - * @return {clay.Mesh} - * @example - * // Create a white cube. - * app.createCube() - */ -App3D.prototype.createCube = function (material, parentNode, subdiv) { - if (subdiv == null) { - subdiv = 1; - } - if (typeof subdiv === 'number') { - subdiv = [subdiv, subdiv, subdiv]; - } - - const geoKey = 'cube-' + subdiv.join('-'); - let cube = this._geoCache.get(geoKey); - if (!cube) { - cube = new CubeGeo({ - widthSegments: subdiv[0], - heightSegments: subdiv[1], - depthSegments: subdiv[2] - }); - cube.generateTangents(); - this._geoCache.put(geoKey, cube); - } - return this.createMesh(cube, material, parentNode); -}; - -/** - * Create a cube mesh that camera is inside the cube. - * @function - * @param {Object|clay.Material} [material] - * @param {clay.Node} [parentNode] Parent node to append. Default to be scene. - * @param {Array.|number} [subdivision=1] Subdivision of cube. - * Can be a number to represent both width, height and depth dimensions. Or an array to represent them respectively. - * @return {clay.Mesh} - * @example - * // Create a white cube inside. - * app.createCubeInside() - */ -App3D.prototype.createCubeInside = function (material, parentNode, subdiv) { - if (subdiv == null) { - subdiv = 1; - } - if (typeof subdiv === 'number') { - subdiv = [subdiv, subdiv, subdiv]; - } - const geoKey = 'cubeInside-' + subdiv.join('-'); - let cube = this._geoCache.get(geoKey); - if (!cube) { - cube = new CubeGeo({ - inside: true, - widthSegments: subdiv[0], - heightSegments: subdiv[1], - depthSegments: subdiv[2] - }); - cube.generateTangents(); - this._geoCache.put(geoKey, cube); - } - - return this.createMesh(cube, material, parentNode); -}; - -/** - * Create a sphere mesh and add it to the scene or the given parent node. - * @function - * @param {Object|clay.Material} [material] - * @param {clay.Node} [parentNode] Parent node to append. Default to be scene. - * @param {number} [subdivision=20] Subdivision of sphere. - * @return {clay.Mesh} - * @example - * // Create a semi-transparent sphere. - * app.createSphere({ - * color: [0, 0, 1], - * transparent: true, - * alpha: 0.5 - * }) - */ -App3D.prototype.createSphere = function (material, parentNode, subdivision) { - if (subdivision == null) { - subdivision = 20; - } - const geoKey = 'sphere-' + subdivision; - let sphere = this._geoCache.get(geoKey); - if (!sphere) { - sphere = new SphereGeo({ - widthSegments: subdivision * 2, - heightSegments: subdivision - }); - sphere.generateTangents(); - this._geoCache.put(geoKey, sphere); - } - return this.createMesh(sphere, material, parentNode); -}; - -// TODO may be modified? -/** - * Create a plane mesh and add it to the scene or the given parent node. - * @function - * @param {Object|clay.Material} [material] - * @param {clay.Node} [parentNode] Parent node to append. Default to be scene. - * @param {Array.|number} [subdivision=1] Subdivision of plane. - * Can be a number to represent both width and height dimensions. Or an array to represent them respectively. - * @return {clay.Mesh} - * @example - * // Create a red color plane. - * app.createPlane({ - * color: [1, 0, 0] - * }) - */ -App3D.prototype.createPlane = function (material, parentNode, subdiv) { - if (subdiv == null) { - subdiv = 1; - } - if (typeof subdiv === 'number') { - subdiv = [subdiv, subdiv]; - } - const geoKey = 'plane-' + subdiv.join('-'); - let planeGeo = this._geoCache.get(geoKey); - if (!planeGeo) { - planeGeo = new PlaneGeo({ - widthSegments: subdiv[0], - heightSegments: subdiv[1] - }); - planeGeo.generateTangents(); - this._geoCache.put(geoKey, planeGeo); - } - return this.createMesh(planeGeo, material, parentNode); -}; - -/** - * Create mesh with parametric surface function - * @param {Object|clay.Material} [material] - * @param {clay.Node} [parentNode] Parent node to append. Default to be scene. - * @param {Object} generator - * @param {Function} generator.x - * @param {Function} generator.y - * @param {Function} generator.z - * @param {Array} [generator.u=[0, 1, 0.05]] - * @param {Array} [generator.v=[0, 1, 0.05]] - * @return {clay.Mesh} - */ -App3D.prototype.createParametricSurface = function (material, parentNode, generator) { - const geo = new ParametricSurfaceGeo({ - generator: generator - }); - geo.generateTangents(); - return this.createMesh(geo, material, parentNode); -}; - -/** - * Create a general mesh with given geometry instance and material config. - * @param {clay.Geometry} geometry - * @return {clay.Mesh} - */ -App3D.prototype.createMesh = function (geometry, mat, parentNode) { - const mesh = new Mesh({ - geometry: geometry, - material: mat instanceof Material ? mat : this.createMaterial(mat) - }); - parentNode = parentNode || this.scene; - parentNode.add(mesh); - return mesh; -}; - -/** - * Create an empty node - * @param {clay.Node} parentNode - * @return {clay.Node} - */ -App3D.prototype.createNode = function (parentNode) { - const node = new Node(); - parentNode = parentNode || this.scene; - parentNode.add(node); - return node; -}; - -/** - * Create a perspective or orthographic camera and add it to the scene. - * @param {Array.|clay.Vector3} position - * @param {Array.|clay.Vector3} target - * @param {string} [type="perspective"] Can be 'perspective' or 'orthographic'(in short 'ortho') - * @param {Array.|clay.Vector3} [extent] Extent is available only if type is orthographic. - * @return {clay.camera.Perspective|clay.camera.Orthographic} - */ -App3D.prototype.createCamera = function (position, target, type, extent) { - let CameraCtor; - let isOrtho = false; - if (type === 'ortho' || type === 'orthographic') { - isOrtho = true; - CameraCtor = OrthographicCamera; - } else { - if (type && type !== 'perspective') { - console.error('Unkown camera type ' + type + '. Use default perspective camera'); - } - CameraCtor = PerspectiveCamera; - } - - const camera = new CameraCtor(); - if (position instanceof Vector3) { - camera.position.copy(position); - } else if (position instanceof Array) { - camera.position.setArray(position); - } - - if (target instanceof Array) { - target = new Vector3(target[0], target[1], target[2]); - } - if (target instanceof Vector3) { - camera.lookAt(target); - } - - if (extent && isOrtho) { - extent = extent.array || extent; - camera.left = -extent[0] / 2; - camera.right = extent[0] / 2; - camera.top = extent[1] / 2; - camera.bottom = -extent[1] / 2; - camera.near = 0; - camera.far = extent[2]; - } else { - camera.aspect = this.renderer.getViewportAspect(); - } - - this.scene.add(camera); - - return camera; -}; - -/** - * Create a directional light and add it to the scene. - * @param {Array.|clay.Vector3} dir A Vector3 or array to represent the direction. - * @param {Color} [color='#fff'] Color of directional light, default to be white. - * @param {number} [intensity] Intensity of directional light, default to be 1. - * - * @example - * app.createDirectionalLight([-1, -1, -1], '#fff', 2); - */ -App3D.prototype.createDirectionalLight = function (dir, color, intensity) { - const light = new DirectionalLight(); - if (dir instanceof Vector3) { - dir = dir.array; - } - light.position.setArray(dir).negate(); - light.lookAt(Vector3.ZERO); - if (typeof color === 'string') { - color = parseColor(color); - } - color != null && (light.color = color); - intensity != null && (light.intensity = intensity); - - this.scene.add(light); - return light; -}; - -/** - * Create a spot light and add it to the scene. - * @param {Array.|clay.Vector3} position Position of the spot light. - * @param {Array.|clay.Vector3} [target] Target position where spot light points to. - * @param {number} [range=20] Falloff range of spot light. Default to be 20. - * @param {Color} [color='#fff'] Color of spot light, default to be white. - * @param {number} [intensity=1] Intensity of spot light, default to be 1. - * @param {number} [umbraAngle=30] Umbra angle of spot light. Default to be 30 degree from the middle line. - * @param {number} [penumbraAngle=45] Penumbra angle of spot light. Default to be 45 degree from the middle line. - * - * @example - * app.createSpotLight([5, 5, 5], [0, 0, 0], 20, #900); - */ -App3D.prototype.createSpotLight = function ( - position, - target, - range, - color, - intensity, - umbraAngle, - penumbraAngle -) { - const light = new SpotLight(); - light.position.setArray(position instanceof Vector3 ? position.array : position); - - if (target instanceof Array) { - target = new Vector3(target[0], target[1], target[2]); - } - if (target instanceof Vector3) { - light.lookAt(target); - } - - if (typeof color === 'string') { - color = parseColor(color); - } - range != null && (light.range = range); - color != null && (light.color = color); - intensity != null && (light.intensity = intensity); - umbraAngle != null && (light.umbraAngle = umbraAngle); - penumbraAngle != null && (light.penumbraAngle = penumbraAngle); - - this.scene.add(light); - - return light; -}; - -/** - * Create a point light. - * @param {Array.|clay.Vector3} position Position of point light.. - * @param {number} [range=100] Falloff range of point light. - * @param {Color} [color='#fff'] Color of point light. - * @param {number} [intensity=1] Intensity of point light. - */ -App3D.prototype.createPointLight = function (position, range, color, intensity) { - const light = new PointLight(); - light.position.setArray(position instanceof Vector3 ? position.array : position); - - if (typeof color === 'string') { - color = parseColor(color); - } - range != null && (light.range = range); - color != null && (light.color = color); - intensity != null && (light.intensity = intensity); - - this.scene.add(light); - - return light; -}; - -/** - * Create a ambient light. - * @param {Color} [color='#fff'] Color of ambient light. - * @param {number} [intensity=1] Intensity of ambient light. - */ -App3D.prototype.createAmbientLight = function (color, intensity) { - const light = new AmbientLight(); - - if (typeof color === 'string') { - color = parseColor(color); - } - color != null && (light.color = color); - intensity != null && (light.intensity = intensity); - - this.scene.add(light); - - return light; -}; - -/** - * Create an cubemap ambient light and an spherical harmonic ambient light - * for specular and diffuse lighting in PBR rendering - * @param {ImageLike|TextureCube} [envImage] Panorama environment image, HDR format is better. Or a pre loaded texture cube - * @param {number} [specularIntenstity=0.7] Intensity of specular light. - * @param {number} [diffuseIntenstity=0.7] Intensity of diffuse light. - * @param {number} [exposure=1] Exposure of HDR image. Only if image in first paramter is HDR. - * @param {number} [prefilteredCubemapSize=32] The size of prefilerted cubemap. Larger value will take more time to do expensive prefiltering. - * @return {Promise} - */ -App3D.prototype.createAmbientCubemapLight = function ( - envImage, - specIntensity, - diffIntensity, - exposure, - prefilteredCubemapSize -) { - const self = this; - if (exposure == null) { - exposure = 0; - } - if (prefilteredCubemapSize == null) { - prefilteredCubemapSize = 32; - } - - const scene = this.scene; - - let loadPromise; - if (envImage.textureType === 'textureCube') { - loadPromise = envImage.isRenderable() - ? Promise.resolve(envImage) - : new Promise(function (resolve) { - envImage.success(function () { - resolve(envImage); - }); - }); - } else { - loadPromise = this.loadTexture(envImage, { - exposure: exposure - }); - } - - return loadPromise.then(function (envTexture) { - const specLight = new AmbientCubemapLight({ - intensity: specIntensity != null ? specIntensity : 0.7 - }); - specLight.cubemap = envTexture; - envTexture.flipY = false; - // TODO Cache prefilter ? - specLight.prefilter(self.renderer, 32); - - const diffLight = new AmbientSHLight({ - intensity: diffIntensity != null ? diffIntensity : 0.7, - coefficients: shUtil.projectEnvironmentMap(self.renderer, specLight.cubemap, { - lod: 1 - }) - }); - scene.add(specLight); - scene.add(diffLight); - - return { - specular: specLight, - diffuse: diffLight, - // Original environment map - environmentMap: envTexture - }; - }); -}; - -/** - * Load a [glTF](https://github.com/KhronosGroup/glTF) format model. - * You can convert FBX/DAE/OBJ format models to [glTF](https://github.com/KhronosGroup/glTF) with [fbx2gltf](https://github.com/pissang/claygl#fbx-to-gltf20-converter) python script, - * or simply using the [Clay Viewer](https://github.com/pissang/clay-viewer) client application. - * @param {string} url - * @param {Object} opts - * @param {string|clay.Shader} [opts.shader='clay.standard'] 'basic'|'lambert'|'standard'. - * @param {boolean} [opts.waitTextureLoaded=false] If add to scene util textures are all loaded. - * @param {boolean} [opts.autoPlayAnimation=true] If autoplay the animation of model. - * @param {boolean} [opts.upAxis='y'] Change model to y up if upAxis is 'z' - * @param {boolean} [opts.textureFlipY=false] - * @param {boolean} [opts.textureConvertToPOT=false] If convert texture to power-of-two - * @param {string} [opts.textureRootPath] Root path of texture. Default to be relative with glTF file. - * @param {clay.Node} [parentNode] Parent node that model will be mounted. Default to be scene - * @return {Promise} - */ -App3D.prototype.loadModel = function (url, opts, parentNode) { - if (typeof url !== 'string') { - throw new Error('Invalid URL.'); - } - - opts = opts || {}; - if (opts.autoPlayAnimation == null) { - opts.autoPlayAnimation = true; - } - const shader = opts.shader || 'clay.standard'; - - const loaderOpts = { - rootNode: new Node(), - shader: shader, - textureRootPath: opts.textureRootPath, - crossOrigin: 'Anonymous', - textureFlipY: opts.textureFlipY, - textureConvertToPOT: opts.textureConvertToPOT - }; - if (opts.upAxis && opts.upAxis.toLowerCase() === 'z') { - loaderOpts.rootNode.rotation.identity().rotateX(-Math.PI / 2); - } - - const loader = new GLTFLoader(loaderOpts); - - parentNode = parentNode || this.scene; - const timeline = this.timeline; - const self = this; - - return new Promise(function (resolve, reject) { - function afterLoad(result) { - if (self._disposed) { - return; - } - - parentNode.add(result.rootNode); - if (opts.autoPlayAnimation) { - result.clips.forEach(function (clip) { - timeline.addClip(clip); - }); - } - resolve(result); - } - loader.success(function (result) { - if (self._disposed) { - return; - } - - if (!opts.waitTextureLoaded) { - afterLoad(result); - } else { - Promise.all( - result.textures.map(function (texture) { - if (texture.isRenderable()) { - return Promise.resolve(texture); - } - return new Promise(function (resolve) { - texture.success(resolve); - texture.error(resolve); - }); - }) - ) - .then(function () { - afterLoad(result); - }) - .catch(function () { - afterLoad(result); - }); - } - }); - loader.error(function () { - reject(); - }); - loader.load(url); - }); -}; - -// TODO cloneModel - -/** - * Similar to `app.scene.cloneNode`, except it will mount the cloned node to the scene automatically. - * See more in {@link clay.Scene#cloneNode} - * - * @param {clay.Node} node - * @param {clay.Node} [parentNode] Parent node that new cloned node will be mounted. - * Default to have same parent with source node. - * @return {clay.Node} - */ -App3D.prototype.cloneNode = function (node, parentNode) { - parentNode = parentNode || node.getParent(); - - const newNode = this.scene.cloneNode(node, parentNode); - if (parentNode) { - parentNode.add(newNode); - } - - return newNode; -}; - export default { App3D: App3D, /** diff --git a/src/claygl.ts b/src/claygl.ts index 8aa05746..3df5bb68 100644 --- a/src/claygl.ts +++ b/src/claygl.ts @@ -68,3 +68,5 @@ export { default as Skybox } from './plugin/Skybox'; export * as meshUtil from './util/mesh'; export * as textureUtil from './util/texture'; + +export { default as App3D } from './App3D'; diff --git a/src/geometry/ParametricSurface.ts b/src/geometry/ParametricSurface.ts index ed5995fa..fe12f29e 100644 --- a/src/geometry/ParametricSurface.ts +++ b/src/geometry/ParametricSurface.ts @@ -9,7 +9,7 @@ interface SurfaceGenerator { z: (u: number, v: number) => number; } -interface ParametricSurfaceGeometryOpts extends GeometryOpts { +export interface ParametricSurfaceGeometryOpts extends GeometryOpts { generator: SurfaceGenerator; } diff --git a/src/light/AmbientSH.ts b/src/light/AmbientSH.ts index 94920881..3d6d1381 100644 --- a/src/light/AmbientSH.ts +++ b/src/light/AmbientSH.ts @@ -1,10 +1,10 @@ import Light, { LightOpts } from '../Light'; export interface AmbientSHLightOpts extends LightOpts { - coefficients: number[]; + coefficients: ArrayLike; } class AmbientSHLight extends Light { - coefficients: number[]; + coefficients: ArrayLike; readonly type = 'AMBIENT_SH_LIGHT'; diff --git a/src/loader/GLTF.ts b/src/loader/GLTF.ts index f84363da..2224c1dc 100644 --- a/src/loader/GLTF.ts +++ b/src/loader/GLTF.ts @@ -246,7 +246,7 @@ interface GLTFLoadOpts { export function load( url: string, - opts?: Omit + opts?: Partial> ): Promise { return new Promise((resolve, reject) => { doLoadGLTF( @@ -263,7 +263,7 @@ export function load( }); } -function doLoadGLTF(url: string, opts?: GLTFLoadOpts) { +function doLoadGLTF(url: string, opts?: Partial) { opts = Object.assign( { useStandardMaterial: false, @@ -316,7 +316,7 @@ function doLoadGLTF(url: string, opts?: GLTFLoadOpts) { * @param {ArrayBuffer} buffer * @return {clay.loader.GLTF.Result} */ -export function parseBinary(buffer: ArrayBuffer, opts: GLTFLoadOpts) { +export function parseBinary(buffer: ArrayBuffer, opts: Partial) { const header = new Uint32Array(buffer, 0, 4); const onerror = opts.onerror; if (header[0] !== 0x46546c67) { @@ -370,7 +370,11 @@ export function parseBinary(buffer: ArrayBuffer, opts: GLTFLoadOpts) { * @param {ArrayBuffer[]} [buffer] * @return {clay.loader.GLTF.Result} */ -export function parse(json: GLTFFormat, buffers: ArrayBuffer[] | undefined, opts: GLTFLoadOpts) { +export function parse( + json: GLTFFormat, + buffers: ArrayBuffer[] | undefined, + opts: Partial +) { const lib: ParsedLib = { json: json, buffers: [], @@ -494,7 +498,7 @@ export function parse(json: GLTFFormat, buffers: ArrayBuffer[] | undefined, opts * Binary file path resolver. User can override it * @param {string} path */ -function resolveBufferPath(path: string, opts: GLTFLoadOpts) { +function resolveBufferPath(path: string, opts: Partial) { if (path && path.match(/^data:(.*?)base64,/)) { return path; } @@ -506,7 +510,7 @@ function resolveBufferPath(path: string, opts: GLTFLoadOpts) { * Texture file path resolver. User can override it * @param {string} path */ -function resolveTexturePath(path: string, opts: GLTFLoadOpts) { +function resolveTexturePath(path: string, opts: Partial) { if (path && path.match(/^data:(.*?)base64,/)) { return path; } @@ -518,7 +522,7 @@ function loadBuffers( path: string, onsuccess: (buffer: ArrayBuffer) => void, onerror: (err: any) => void, - opts: GLTFLoadOpts + opts: Partial ) { const base64Prefix = 'data:application/octet-stream;base64,'; const strStart = path.substr(0, base64Prefix.length); @@ -540,7 +544,7 @@ function loadBuffers( // https://github.com/KhronosGroup/glTF/issues/100 // https://github.com/KhronosGroup/glTF/issues/193 -function parseSkins(json: GLTFFormat, lib: ParsedLib, opts: GLTFLoadOpts) { +function parseSkins(json: GLTFFormat, lib: ParsedLib, opts: Partial) { // Create skeletons and joints (json.skins || []).forEach((skinInfo: GLTFSkin, idx: number) => { const skeleton = new Skeleton(skinInfo.name); @@ -600,7 +604,7 @@ function parseSkins(json: GLTFFormat, lib: ParsedLib, opts: GLTFLoadOpts) { }); } -function parseTextures(json: GLTFFormat, lib: ParsedLib, opts: GLTFLoadOpts) { +function parseTextures(json: GLTFFormat, lib: ParsedLib, opts: Partial) { (json.textures || []).forEach((textureInfo: GLTFTexture, idx: number) => { // samplers is optional const samplerInfo = (json.samplers && json.samplers[textureInfo.sampler]) || {}; @@ -648,7 +652,7 @@ function parseTextures(json: GLTFFormat, lib: ParsedLib, opts: GLTFLoadOpts) { function KHRCommonMaterialToStandard( materialInfo: GLTFMaterial, lib: ParsedLib, - opts: GLTFLoadOpts + opts: Partial ) { /* eslint-disable-next-line */ const commonMaterialInfo = materialInfo.extensions['KHR_materials_common']; @@ -760,7 +764,7 @@ function pbrMetallicRoughnessToStandard( materialInfo: GLTFMaterial, metallicRoughnessMatInfo: any, lib: ParsedLib, - opts: GLTFLoadOpts + opts: Partial ) { const alphaTest = materialInfo.alphaMode === 'MASK'; @@ -879,7 +883,7 @@ function pbrSpecularGlossinessToStandard( materialInfo: GLTFMaterial, specularGlossinessMatInfo: any, lib: ParsedLib, - opts: GLTFLoadOpts + opts: Partial ) { const alphaTest = materialInfo.alphaMode === 'MASK'; @@ -963,7 +967,7 @@ function pbrSpecularGlossinessToStandard( return material; } -function parseMaterials(json: GLTFFormat, lib: ParsedLib, opts: GLTFLoadOpts) { +function parseMaterials(json: GLTFFormat, lib: ParsedLib, opts: Partial) { (json.materials || []).forEach((materialInfo: GLTFMaterial, idx: number) => { /* eslint-disable-next-line */ if (materialInfo.extensions && materialInfo.extensions['KHR_materials_common']) { @@ -991,7 +995,7 @@ function parseMaterials(json: GLTFFormat, lib: ParsedLib, opts: GLTFLoadOpts) { }); } -function parseMeshes(json: GLTFFormat, lib: ParsedLib, opts: GLTFLoadOpts) { +function parseMeshes(json: GLTFFormat, lib: ParsedLib, opts: Partial) { (json.meshes || []).forEach((meshInfo: GLTFMesh, idx: number) => { lib.meshes[idx] = []; // Geometry @@ -1162,7 +1166,7 @@ function instanceCamera(json: GLTFFormat, nodeInfo: GLTFNode) { } } -function parseNodes(json: GLTFFormat, lib: ParsedLib, opts: GLTFLoadOpts) { +function parseNodes(json: GLTFFormat, lib: ParsedLib, opts: Partial) { function instanceMesh(mesh: Mesh): Mesh { return new Mesh({ name: mesh.name, @@ -1232,7 +1236,7 @@ function parseNodes(json: GLTFFormat, lib: ParsedLib, opts: GLTFLoadOpts) { }); } -function parseAnimations(json: GLTFFormat, lib: ParsedLib, opts: GLTFLoadOpts) { +function parseAnimations(json: GLTFFormat, lib: ParsedLib, opts: Partial) { function checkChannelPath(channelInfo: GLTFChannel) { if (channelInfo.path === 'weights') { console.warn('GLTFLoader not support morph targets yet.'); diff --git a/src/picking/RayPicking.ts b/src/picking/RayPicking.ts deleted file mode 100644 index c10fb6b6..00000000 --- a/src/picking/RayPicking.ts +++ /dev/null @@ -1,262 +0,0 @@ -// @ts-nocheck -import Base from '../core/Base'; -import Ray from '../math/Ray'; -import Vector2 from '../math/Vector2'; -import Vector3 from '../math/Vector3'; -import Matrix4 from '../math/Matrix4'; -import Renderable from '../Renderable'; -import * as glenum from '../core/glenum'; -import vec3 from '../glmatrix/vec3'; - -/** - * @constructor clay.picking.RayPicking - * @extends clay.core.Base - */ -const RayPicking = Base.extend( - /** @lends clay.picking.RayPicking# */ { - /** - * Target scene - * @type {clay.Scene} - */ - scene: null, - /** - * Target camera - * @type {clay.Camera} - */ - camera: null, - /** - * Target renderer - * @type {clay.Renderer} - */ - renderer: null - }, - function () { - this._ray = new Ray(); - this._ndc = new Vector2(); - }, - /** @lends clay.picking.RayPicking.prototype */ - { - /** - * Pick the nearest intersection object in the scene - * @param {number} x Mouse position x - * @param {number} y Mouse position y - * @param {boolean} [forcePickAll=false] ignore ignorePicking - * @return {clay.picking.RayPicking~Intersection} - */ - pick: function (x, y, forcePickAll) { - const out = this.pickAll(x, y, [], forcePickAll); - return out[0] || null; - }, - - /** - * Pick all intersection objects, wich will be sorted from near to far - * @param {number} x Mouse position x - * @param {number} y Mouse position y - * @param {Array} [output] - * @param {boolean} [forcePickAll=false] ignore ignorePicking - * @return {Array.} - */ - pickAll: function (x, y, output, forcePickAll) { - this.renderer.screenToNDC(x, y, this._ndc); - this.camera.castRay(this._ndc, this._ray); - - output = output || []; - - this._intersectNode(this.scene, output, forcePickAll || false); - - output.sort(this._intersectionCompareFunc); - - return output; - }, - - _intersectNode: function (node, out, forcePickAll) { - if (node instanceof Renderable && node.isRenderable()) { - if ( - (!node.ignorePicking || forcePickAll) && - // Only triangle mesh support ray picking - ((node.mode === glenum.TRIANGLES && node.geometry.isUseIndices()) || - // Or if geometry has it's own pickByRay, pick, implementation - node.geometry.pickByRay || - node.geometry.pick) - ) { - this._intersectRenderable(node, out); - } - } - for (let i = 0; i < node._children.length; i++) { - this._intersectNode(node._children[i], out, forcePickAll); - } - }, - - _intersectRenderable: (function () { - const v1 = new Vector3(); - const v2 = new Vector3(); - const v3 = new Vector3(); - const ray = new Ray(); - const worldInverse = new Matrix4(); - - return function (renderable, out) { - let isSkinnedMesh = renderable.isSkinnedMesh(); - ray.copy(this._ray); - Matrix4.invert(worldInverse, renderable.worldTransform); - - // Skinned mesh will ignore the world transform. - if (!isSkinnedMesh) { - ray.applyTransform(worldInverse); - } - - const geometry = renderable.geometry; - - const bbox = isSkinnedMesh ? renderable.skeleton.boundingBox : geometry.boundingBox; - - if (bbox && !ray.intersectBoundingBox(bbox)) { - return; - } - // Use user defined picking algorithm - if (geometry.pick) { - geometry.pick(this._ndc.x, this._ndc.y, this.renderer, this.camera, renderable, out); - return; - } - // Use user defined ray picking algorithm - else if (geometry.pickByRay) { - geometry.pickByRay(ray, renderable, out); - return; - } - - const cullBack = - (renderable.cullFace === glenum.BACK && renderable.frontFace === glenum.CCW) || - (renderable.cullFace === glenum.FRONT && renderable.frontFace === glenum.CW); - - let point; - const indices = geometry.indices; - const positionAttr = geometry.attributes.position; - const weightAttr = geometry.attributes.weight; - const jointAttr = geometry.attributes.joint; - let skinMatricesArray; - const skinMatrices = []; - // Check if valid. - if (!positionAttr || !positionAttr.value || !indices) { - return; - } - if (isSkinnedMesh) { - skinMatricesArray = renderable.skeleton.getSubSkinMatrices( - renderable.__uid__, - renderable.joints - ); - for (let i = 0; i < renderable.joints.length; i++) { - skinMatrices[i] = skinMatrices[i] || []; - for (let k = 0; k < 16; k++) { - skinMatrices[i][k] = skinMatricesArray[i * 16 + k]; - } - } - const pos = []; - const weight = []; - const joint = []; - const skinnedPos = []; - const tmp = []; - let skinnedPositionAttr = geometry.attributes.skinnedPosition; - if (!skinnedPositionAttr || !skinnedPositionAttr.value) { - geometry.createAttribute('skinnedPosition', 'f', 3); - skinnedPositionAttr = geometry.attributes.skinnedPosition; - skinnedPositionAttr.init(geometry.vertexCount); - } - for (let i = 0; i < geometry.vertexCount; i++) { - positionAttr.get(i, pos); - weightAttr.get(i, weight); - jointAttr.get(i, joint); - weight[3] = 1 - weight[0] - weight[1] - weight[2]; - vec3.set(skinnedPos, 0, 0, 0); - for (let k = 0; k < 4; k++) { - if (joint[k] >= 0 && weight[k] > 1e-4) { - vec3.transformMat4(tmp, pos, skinMatrices[joint[k]]); - vec3.scaleAndAdd(skinnedPos, skinnedPos, tmp, weight[k]); - } - } - skinnedPositionAttr.set(i, skinnedPos); - } - } - - for (let i = 0; i < indices.length; i += 3) { - const i1 = indices[i]; - const i2 = indices[i + 1]; - const i3 = indices[i + 2]; - const finalPosAttr = isSkinnedMesh ? geometry.attributes.skinnedPosition : positionAttr; - finalPosAttr.get(i1, v1.array); - finalPosAttr.get(i2, v2.array); - finalPosAttr.get(i3, v3.array); - - if (cullBack) { - point = ray.intersectTriangle(v1, v2, v3, renderable.culling); - } else { - point = ray.intersectTriangle(v1, v3, v2, renderable.culling); - } - if (point) { - const pointW = new Vector3(); - if (!isSkinnedMesh) { - Vector3.transformMat4(pointW, point, renderable.worldTransform); - } else { - // TODO point maybe not right. - Vector3.copy(pointW, point); - } - out.push( - new RayPicking.Intersection( - point, - pointW, - renderable, - [i1, i2, i3], - i / 3, - Vector3.dist(pointW, this._ray.origin) - ) - ); - } - } - }; - })(), - - _intersectionCompareFunc: function (a, b) { - return a.distance - b.distance; - } - } -); - -/** - * @constructor clay.picking.RayPicking~Intersection - * @param {clay.Vector3} point - * @param {clay.Vector3} pointWorld - * @param {clay.Node} target - * @param {Array.} triangle - * @param {number} triangleIndex - * @param {number} distance - */ -RayPicking.Intersection = function (point, pointWorld, target, triangle, triangleIndex, distance) { - /** - * Intersection point in local transform coordinates - * @type {clay.Vector3} - */ - this.point = point; - /** - * Intersection point in world transform coordinates - * @type {clay.Vector3} - */ - this.pointWorld = pointWorld; - /** - * Intersection scene node - * @type {clay.Node} - */ - this.target = target; - /** - * Intersection triangle, which is an array of vertex index - * @type {Array.} - */ - this.triangle = triangle; - /** - * Index of intersection triangle. - */ - this.triangleIndex = triangleIndex; - /** - * Distance from intersection point to ray origin - * @type {number} - */ - this.distance = distance; -}; - -export default RayPicking; diff --git a/src/picking/rayPicking.ts b/src/picking/rayPicking.ts new file mode 100644 index 00000000..2bd01418 --- /dev/null +++ b/src/picking/rayPicking.ts @@ -0,0 +1,273 @@ +import Ray from '../math/Ray'; +import Vector2 from '../math/Vector2'; +import Vector3 from '../math/Vector3'; +import Matrix4 from '../math/Matrix4'; +import { mat4, vec3, vec4 } from '../glmatrix'; +import type Renderable from '../Renderable'; +import * as glenum from '../core/glenum'; +import type Renderer from '../Renderer'; +import type Scene from '../Scene'; +import type Camera from '../Camera'; +import type ClayNode from '../Node'; +import type { GeometryAttribute } from '../GeometryBase'; + +/** + * Pick all intersection objects, wich will be sorted from near to far + * @param x Mouse position x + * @param y Mouse position y + * @param output + * @param forcePickAll ignore ignorePicking + */ +export function pickAll( + renderer: Renderer, + scene: Scene, + camera: Camera, + x: number, + y: number, + output?: Intersection[], + forcePickAll?: boolean +): Intersection[] { + const ray = new Ray(); + const ndc = new Vector2(); + renderer.screenToNDC(x, y, ndc); + camera.castRay(ndc, ray); + + output = output || []; + + intersectNode(renderer, camera, ray, ndc, scene, output, forcePickAll || false); + + output.sort(intersectionCompareFunc); + + return output; +} + +/** + * Pick the nearest intersection object in the scene + * @param x Mouse position x + * @param y Mouse position y + * @param forcePickAll ignore ignorePicking + */ +export function pick( + renderer: Renderer, + scene: Scene, + camera: Camera, + x: number, + y: number, + forcePickAll?: boolean +): Intersection | undefined { + return pickAll(renderer, scene, camera, x, y, [], forcePickAll)[0]; +} + +function intersectNode( + renderer: Renderer, + camera: Camera, + ray: Ray, + ndc: Vector2, + + node: ClayNode, + out: Intersection[], + forcePickAll: boolean +) { + if (node.isRenderable && node.isRenderable()) { + if ( + (!node.ignorePicking || forcePickAll) && + // Only triangle mesh support ray picking + ((node.mode === glenum.TRIANGLES && node.geometry.isUseIndices()) || + // Or if geometry has it's own pickByRay, pick, implementation + node.geometry.pickByRay || + node.geometry.pick) + ) { + intersectRenderable(renderer, camera, ray, ndc, node, out); + } + } + const childrenRef = node.childrenRef(); + for (let i = 0; i < childrenRef.length; i++) { + intersectNode(renderer, camera, ray, ndc, childrenRef[i], out, forcePickAll); + } +} + +const v1 = new Vector3(); +const v2 = new Vector3(); +const v3 = new Vector3(); +const ray = new Ray(); +const worldInverse = new Matrix4(); + +function intersectRenderable( + renderer: Renderer, + camera: Camera, + ray: Ray, + ndc: Vector2, + renderable: Renderable, + out: Intersection[] +) { + const isSkinnedMesh = renderable.isSkinnedMesh(); + ray.copy(ray); + Matrix4.invert(worldInverse, renderable.worldTransform); + + // Skinned mesh will ignore the world transform. + if (!isSkinnedMesh) { + ray.applyTransform(worldInverse); + } + + const geometry = renderable.geometry; + + const bbox = isSkinnedMesh ? renderable.skeleton.boundingBox : geometry.boundingBox; + + if (bbox && !ray.intersectBoundingBox(bbox)) { + return; + } + // Use user defined picking algorithm + if (geometry.pick) { + geometry.pick(ndc.x, ndc.y, renderer, camera, renderable, out); + return; + } + // Use user defined ray picking algorithm + else if (geometry.pickByRay) { + geometry.pickByRay(ray, renderable, out); + return; + } + + const cullBack = + (renderable.cullFace === glenum.BACK && renderable.frontFace === glenum.CCW) || + (renderable.cullFace === glenum.FRONT && renderable.frontFace === glenum.CW); + + let point; + const indices = geometry.indices; + const positionAttr = geometry.attributes.position; + const weightAttr = geometry.attributes.weight; + const jointAttr = geometry.attributes.joint; + let skinMatricesArray; + const skinMatrices: mat4.Mat4Array[] = []; + // Check if valid. + if (!positionAttr || !positionAttr.value || !indices) { + return; + } + if (isSkinnedMesh) { + skinMatricesArray = renderable.skeleton.getSubSkinMatrices( + renderable.__uid__, + renderable.joints + ); + for (let i = 0; i < renderable.joints.length; i++) { + skinMatrices[i] = skinMatrices[i] || []; + for (let k = 0; k < 16; k++) { + skinMatrices[i][k] = skinMatricesArray[i * 16 + k]; + } + } + const pos = vec3.create(); + const weight = vec4.create(); + const joint = vec4.create(); + const skinnedPos = vec3.create(); + const tmp = vec3.create(); + let skinnedPositionAttr = geometry.attributes.skinnedPosition as GeometryAttribute<3>; + if (!skinnedPositionAttr || !skinnedPositionAttr.value) { + geometry.createAttribute('skinnedPosition', 'float', 3); + skinnedPositionAttr = geometry.attributes.skinnedPosition as GeometryAttribute<3>; + skinnedPositionAttr.init(geometry.vertexCount); + } + for (let i = 0; i < geometry.vertexCount; i++) { + positionAttr.get(i, pos); + weightAttr.get(i, weight); + jointAttr.get(i, joint); + weight[3] = 1 - weight[0] - weight[1] - weight[2]; + vec3.set(skinnedPos, 0, 0, 0); + for (let k = 0; k < 4; k++) { + if (joint[k] >= 0 && weight[k] > 1e-4) { + vec3.transformMat4(tmp, pos, skinMatrices[joint[k]]); + vec3.scaleAndAdd(skinnedPos, skinnedPos, tmp, weight[k]); + } + } + skinnedPositionAttr.set(i, skinnedPos); + } + } + + for (let i = 0; i < indices.length; i += 3) { + const i1 = indices[i]; + const i2 = indices[i + 1]; + const i3 = indices[i + 2]; + const finalPosAttr = isSkinnedMesh ? geometry.attributes.skinnedPosition : positionAttr; + finalPosAttr.get(i1, v1.array); + finalPosAttr.get(i2, v2.array); + finalPosAttr.get(i3, v3.array); + + if (cullBack) { + point = ray.intersectTriangle(v1, v2, v3, renderable.culling); + } else { + point = ray.intersectTriangle(v1, v3, v2, renderable.culling); + } + if (point) { + const pointW = new Vector3(); + if (!isSkinnedMesh) { + Vector3.transformMat4(pointW, point, renderable.worldTransform); + } else { + // TODO point maybe not right. + Vector3.copy(pointW, point); + } + out.push( + new Intersection( + point, + pointW, + renderable, + [i1, i2, i3], + i / 3, + Vector3.dist(pointW, ray.origin) + ) + ); + } + } +} + +function intersectionCompareFunc(a: Intersection, b: Intersection) { + return a.distance - b.distance; +} + +/** + * @constructor clay.picking.RayPicking~Intersection + * @param {clay.Vector3} point + * @param {clay.Vector3} pointWorld + * @param {clay.Node} target + * @param {Array.} triangle + * @param {number} triangleIndex + * @param {number} distance + */ +export class Intersection { + /** + * Intersection point in local transform coordinates + */ + point: Vector3; + /** + * Intersection point in world transform coordinates + */ + pointWorld: Vector3; + /** + * Intersection scene node + */ + target: ClayNode; + /** + * Intersection triangle, which is an array of vertex index + */ + triangle: number[]; + /** + * Index of intersection triangle. + */ + triangleIndex: number; + /** + * Distance from intersection point to ray origin + */ + distance: number; + + constructor( + point: Vector3, + pointWorld: Vector3, + target: ClayNode, + triangle: number[], + triangleIndex: number, + distance: number + ) { + this.point = point; + this.pointWorld = pointWorld; + this.target = target; + this.triangle = triangle; + this.triangleIndex = triangleIndex; + this.distance = distance; + } +} diff --git a/src/plugin/Skybox.ts b/src/plugin/Skybox.ts index 4fb41c49..faad5497 100644 --- a/src/plugin/Skybox.ts +++ b/src/plugin/Skybox.ts @@ -141,6 +141,10 @@ class Skybox extends Mesh { } renderer.renderPass([this], dummyCamera); } + + static getSceneSkybox(scene: Scene) { + return sceneSkyboxMap.get(scene); + } } export default Skybox;