/** * Helpers for creating a common 3d application. * @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 util from './core/util'; import shUtil from './util/sh'; import textureUtil from './util/texture'; import vendor from './core/vendor'; import colorUtil from './core/color'; var parseColor = colorUtil.parseToFloat; import shaderLibrary from './shader/builtin'; import Shader from './Shader'; var EVE_NAMES = ['click', 'dblclick', 'mouseover', 'mouseout', 'mousemove', 'touchstart', 'touchend', 'touchmove', 'mousewheel', 'DOMMouseScroll' ]; /** * @typedef {string|HTMLCanvasElement|HTMLImageElement|HTMLVideoElement} ImageLike */ /** * @typedef {string|HTMLCanvasElement|HTMLImageElement|HTMLVideoElement|clay.Texture2D} TextureLike */ /** * @typedef {string|Array.} Color */ /** * @typedef {HTMLElement|string} DomQuery */ /** * @typedef {Object} App3DNamespace * @property {Function} init Initialization callback that will be called when initing app. * You can return a promise in init to start the loop asynchronously when the promise is resolved. * @property {Function} loop Loop callback that will be called each frame. * @property {boolean} [autoRender=true] If render automatically each frame. * @property {Function} [beforeRender] * @property {Function} [afterRender] * @property {number} [width] Container width. * @property {number} [height] Container height. * @property {number} [devicePixelRatio] * @property {Object.} [methods] Methods that will be injected to App3D#methods. * @property {Object} [graphic] Graphic configuration including shadow, color space. * @property {boolean} [graphic.shadow=false] If enable shadow * @property {boolean} [graphic.linear=false] If use linear color space * @property {boolean} [graphic.tonemapping=false] If enable ACES tone mapping. * @property {boolean} [event=false] If enable mouse/touch event. It will slow down the system if geometries are complex. */ /** * @typedef {Object} StandardMaterialMRConfig * @property {string} [name] * @property {string} [shader='standardMR'] * @property {Color} [color] * @property {number} [alpha] * @property {number} [metalness] * @property {number} [roughness] * @property {Color} [emission] * @property {number} [emissionIntensity] * @property {boolean} [transparent] * @property {TextureLike} [diffuseMap] * @property {TextureLike} [normalMap] * @property {TextureLike} [roughnessMap] * @property {TextureLike} [metalnessMap] * @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 var 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 || {}; if (appNS.autoRender == null) { appNS.autoRender = true; } if (typeof dom === 'string') { dom = document.querySelector(dom); } if (!dom) { throw new Error('Invalid dom'); } var isDomCanvas = !dom.nodeName // Not in dom environment || dom.nodeName.toUpperCase() === 'CANVAS'; var rendererOpts = {}; isDomCanvas && (rendererOpts.canvas = dom); appNS.devicePixelRatio && (rendererOpts.devicePixelRatio = appNS.devicePixelRatio); var gRenderer = new Renderer(rendererOpts); var gWidth = appNS.width || dom.clientWidth; var gHeight = appNS.height || dom.clientHeight; var gScene = new Scene(); var gTimeline = new Timeline(); var gShadowPass = appNS.graphic.shadow && new ShadowMapPass(); var gRayPicking = appNS.event && new RayPicking({ scene: gScene, renderer: gRenderer }); !isDomCanvas && dom.appendChild(gRenderer.canvas); gRenderer.resize(gWidth, gHeight); var gFrameTime = 0; var gElapsedTime = 0; gTimeline.start(); var userMethods = {}; for (var 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) { gWidth = width || appNS.width || dom.clientWidth; gHeight = height || dom.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. var 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.'); } var self = this; initPromise.then(function () { gTimeline.on('frame', function (frameTime) { gFrameTime = frameTime; gElapsedTime += frameTime; var 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) { var 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) { var dom = this.container; var 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(); var box = dom.getBoundingClientRect(); var offsetX, offsetY; var eveType = _eveType; if (eveType.indexOf('touch') >= 0) { var 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; } var pickResult = rayPicking.pick(offsetX, offsetY); var 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 ? var 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) { var enableTonemapping = !!graphicOpts.tonemapping; var isLinearSpace = !!graphicOpts.linear; var prevMaterial; for (var i = 0; i < list.length; i++) { var mat = list[i].material; if (mat === prevMaterial) { continue; } enableTonemapping ? mat.define('fragment', 'TONEMAPPING') : mat.undefine('fragment', 'TONEMAPPING'); if (isLinearSpace) { var 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) { var camera = scene.getMainCamera(); renderer.render(scene, camera, true); }; /** * Do render */ App3D.prototype.render = function () { this._inRender = true; var appNS = this._appNS; appNS.beforeRender && appNS.beforeRender(self); var scene = this.scene; var renderer = this.renderer; var shadowPass = this._shadowPass; scene.update(); var 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(self); this._inRender = false; }; App3D.prototype.collectResources = function () { var renderer = this.renderer; var scene = this.scene; var texturesList = this._texturesList; var geometriesList = this._geometriesList; // Mark all resources unused; markUnused(texturesList); markUnused(geometriesList); // Collect resources used in this frame. var newTexturesList = []; var 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 (var i = 0; i < resourceList.length; i++) { resourceList[i].__used = 0; } } function checkAndDispose(renderer, resourceList) { for (var 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) { var prevMaterial; var prevGeometry; scene.traverse(function (renderable) { if (renderable.isRenderable()) { var geometry = renderable.geometry; var material = renderable.material; // TODO optimize!! if (material !== prevMaterial) { var textureUniforms = material.getTextureUniforms(); for (var u = 0; u < textureUniforms.length; u++) { var uniformName = textureUniforms[u]; var val = material.uniforms[uniformName].value; var uniformType = material.uniforms[uniformName].type; if (!val) { continue; } if (uniformType === 't') { updateUsed(val, textureResourceList); } else if (uniformType === 'tv') { for (var k = 0; k < val.length; k++) { if (val[k]) { updateUsed(val[k], textureResourceList); } } } } } if (geometry !== prevGeometry) { updateUsed(geometry, geometryResourceList); } prevMaterial = material; prevGeometry = geometry; } }); for (var 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) { var self = this; var key = getKeyFromImageLike(urlOrImg); if (useCache) { if (this._texCache.get(key)) { return this._texCache.get(key); } } // TODO Promise ? var promise = new Promise(function (resolve, reject) { var 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 * var texture = app.loadTexture('diffuseMap.jpg', { * anisotropic: 8, * flipY: false * }); * material.set('diffuseMap', texture); */ App3D.prototype.loadTextureSync = function (urlOrImg, opts) { var 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 (var 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) { var 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 * var 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; var 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'; var shader = matConfig.shader instanceof Shader ? matConfig.shader : shaderLibrary.get(matConfig.shader); var material = new Material({ shader: shader }); if (matConfig.name) { material.name = matConfig.name; } var texturesLoading = []; function makeTextureSetter(key) { return function (texture) { material.setUniform(key, texture); matConfig.textureLoaded && matConfig.textureLoaded(key, texture); return texture; }; } for (var key in matConfig) { if (material.uniforms[key]) { var 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.transparent) { matConfig.depthMask = false; matConfig.transparent = true; } if (matConfig.texturesReady) { Promise.all(texturesLoading).then(function (textures) { matConfig.texturesReady(textures); }); } 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]; } var geoKey = 'cube-' + subdiv.join('-'); var 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]; } var geoKey = 'cubeInside-' + subdiv.join('-'); var 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; } var geoKey = 'sphere-' + subdivision; var 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]; } var geoKey = 'plane-' + subdiv.join('-'); var 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) { var 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) { var 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) { var 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) { var CameraCtor; var 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; } var 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) { var 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) { var 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) { var 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) { var 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) { var self = this; if (exposure == null) { exposure = 0; } if (prefilteredCubemapSize == null) { prefilteredCubemapSize = 32; } var scene = this.scene; var loadPromise; if (envImage.textureType === 'textureCube') { loadPromise = envImage.isRenderable() ? Promise.resolve(envImage) : new Promise(function (resolve, reject) { envImage.success(function () { resolve(envImage); }); }); } else { loadPromise = this.loadTexture(envImage, { exposure: exposure }); } return loadPromise.then(function (envTexture) { var specLight = new AmbientCubemapLight({ intensity: specIntensity != null ? specIntensity : 0.7 }); specLight.cubemap = envTexture; envTexture.flipY = false; // TODO Cache prefilter ? specLight.prefilter(self.renderer, 32); var 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; } var shader = opts.shader || 'clay.standard'; var 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); } var loader = new GLTFLoader(loaderOpts); parentNode = parentNode || this.scene; var timeline = this.timeline; var 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(); var newNode = this.scene.cloneNode(node, parentNode); if (parentNode) { parentNode.add(newNode); } return newNode; }; export default { App3D: App3D, /** * Create a 3D application that will manage the app initialization and loop. * * See more details at {@link clay.application.App3D} * * @name clay.application.create * @method * @param {HTMLElement|string} dom Container dom element or a selector string that can be used in `querySelector` * @param {App3DNamespace} appNS Options and namespace used in creating app3D * * @return {clay.application.App3D} * * @example * clay.application.create('#app', { * init: function (app) { * app.createCube(); * var camera = app.createCamera(); * camera.position.set(0, 0, 2); * }, * loop: function () { // noop } * }) */ create: function (dom, appNS) { return new App3D(dom, appNS); } };