UNPKG

cione-comp-lib

Version:

`$ yarn add cione-comp-lib` 或者 `$ npm i cione-comp-lib -S`

565 lines (564 loc) 20.7 kB
import { Vector3, Matrix4, PerspectiveCamera, EventDispatcher } from "../../../../_three@0.150.1@three/build/three.module.mjs"; import { XREstimatedLight } from "../../../../_three@0.150.1@three/examples/jsm/webxr/XREstimatedLight.mjs"; import { $currentEnvironmentMap, $currentBackground } from "../features/environment.mjs"; import { $onResize } from "../model-viewer-base.mjs"; import { assertIsArCandidate } from "../utilities.mjs"; import { Damper } from "./Damper.mjs"; import { PlacementBox } from "./PlacementBox.mjs"; import { ChangeSource } from "./SmoothControls.mjs"; /* @license * Copyright 2019 Google LLC. All Rights Reserved. * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const INIT_FRAMES = 30; const AR_SHADOW_INTENSITY = 0.8; const ROTATION_RATE = 1.5; const HIT_ANGLE_DEG = 20; const SCALE_SNAP_HIGH = 1.3; const SCALE_SNAP_LOW = 1 / SCALE_SNAP_HIGH; const MIN_VIEWPORT_SCALE = 0.25; const MAX_DISTANCE = 10; const ARStatus = { NOT_PRESENTING: "not-presenting", SESSION_STARTED: "session-started", OBJECT_PLACED: "object-placed", FAILED: "failed" }; const ARTracking = { TRACKING: "tracking", NOT_TRACKING: "not-tracking" }; const vector3 = new Vector3(); const matrix4 = new Matrix4(); const hitPosition = new Vector3(); const camera = new PerspectiveCamera(45, 1, 0.1, 100); class ARRenderer extends EventDispatcher { constructor(renderer) { super(); this.renderer = renderer; this.currentSession = null; this.placeOnWall = false; this.placementBox = null; this.lastTick = null; this.turntableRotation = null; this.oldShadowIntensity = null; this.frame = null; this.initialHitSource = null; this.transientHitTestSource = null; this.inputSource = null; this._presentedScene = null; this.resolveCleanup = null; this.exitWebXRButtonContainer = null; this.overlay = null; this.xrLight = null; this.tracking = true; this.frames = 0; this.initialized = false; this.oldTarget = new Vector3(); this.placementComplete = false; this.isTranslating = false; this.isRotating = false; this.isTwoFingering = false; this.lastDragPosition = new Vector3(); this.firstRatio = 0; this.lastAngle = 0; this.goalPosition = new Vector3(); this.goalYaw = 0; this.goalScale = 1; this.xDamper = new Damper(); this.yDamper = new Damper(); this.zDamper = new Damper(); this.yawDamper = new Damper(); this.scaleDamper = new Damper(); this.onExitWebXRButtonContainerClick = () => this.stopPresenting(); this.onUpdateScene = () => { if (this.placementBox != null && this.isPresenting) { this.placementBox.dispose(); this.placementBox = new PlacementBox(this.presentedScene, this.placeOnWall ? "back" : "bottom"); } }; this.onSelectStart = (event) => { const hitSource = this.transientHitTestSource; if (hitSource == null) { return; } const fingers = this.frame.getHitTestResultsForTransientInput(hitSource); const scene = this.presentedScene; const box = this.placementBox; if (fingers.length === 1) { this.inputSource = event.inputSource; const { axes } = this.inputSource.gamepad; const hitPosition2 = box.getHit(this.presentedScene, axes[0], axes[1]); box.show = true; if (hitPosition2 != null) { this.isTranslating = true; this.lastDragPosition.copy(hitPosition2); } else if (this.placeOnWall === false) { this.isRotating = true; this.lastAngle = axes[0] * ROTATION_RATE; } } else if (fingers.length === 2) { box.show = true; this.isTwoFingering = true; const { separation } = this.fingerPolar(fingers); this.firstRatio = separation / scene.scale.x; } }; this.onSelectEnd = () => { this.isTranslating = false; this.isRotating = false; this.isTwoFingering = false; this.inputSource = null; this.goalPosition.y += this.placementBox.offsetHeight * this.presentedScene.scale.x; this.placementBox.show = false; }; this.threeRenderer = renderer.threeRenderer; this.threeRenderer.xr.enabled = true; } async resolveARSession() { assertIsArCandidate(); const session = await navigator.xr.requestSession("immersive-ar", { requiredFeatures: ["hit-test"], optionalFeatures: ["dom-overlay", "light-estimation"], domOverlay: this.overlay ? { root: this.overlay } : void 0 }); this.threeRenderer.xr.setReferenceSpaceType("local"); await this.threeRenderer.xr.setSession(session); this.threeRenderer.xr.cameraAutoUpdate = false; return session; } get presentedScene() { return this._presentedScene; } async supportsPresentation() { try { assertIsArCandidate(); return await navigator.xr.isSessionSupported("immersive-ar"); } catch (error) { console.warn("Request to present in WebXR denied:"); console.warn(error); console.warn("Falling back to next ar-mode"); return false; } } async present(scene, environmentEstimation = false) { if (this.isPresenting) { console.warn("Cannot present while a model is already presenting"); } let waitForAnimationFrame = new Promise((resolve, _reject) => { requestAnimationFrame(() => resolve()); }); scene.setHotspotsVisibility(false); scene.queueRender(); await waitForAnimationFrame; this._presentedScene = scene; this.overlay = scene.element.shadowRoot.querySelector("div.default"); if (environmentEstimation === true) { this.xrLight = new XREstimatedLight(this.threeRenderer); this.xrLight.addEventListener("estimationstart", () => { if (!this.isPresenting || this.xrLight == null) { return; } const scene2 = this.presentedScene; scene2.add(this.xrLight); scene2.environment = this.xrLight.environment; }); } const currentSession = await this.resolveARSession(); currentSession.addEventListener("end", () => { this.postSessionCleanup(); }, { once: true }); const exitButton = scene.element.shadowRoot.querySelector(".slot.exit-webxr-ar-button"); exitButton.classList.add("enabled"); exitButton.addEventListener("click", this.onExitWebXRButtonContainerClick); this.exitWebXRButtonContainer = exitButton; const viewerRefSpace = await currentSession.requestReferenceSpace("viewer"); this.tracking = true; this.frames = 0; this.initialized = false; this.turntableRotation = scene.yaw; this.goalYaw = scene.yaw; this.goalScale = 1; scene.background = null; this.oldShadowIntensity = scene.shadowIntensity; scene.setShadowIntensity(0.01); this.oldTarget.copy(scene.getTarget()); scene.element.addEventListener("load", this.onUpdateScene); const radians = HIT_ANGLE_DEG * Math.PI / 180; const ray = this.placeOnWall === true ? void 0 : new XRRay(new DOMPoint(0, 0, 0), { x: 0, y: -Math.sin(radians), z: -Math.cos(radians) }); currentSession.requestHitTestSource({ space: viewerRefSpace, offsetRay: ray }).then((hitTestSource) => { this.initialHitSource = hitTestSource; }); this.currentSession = currentSession; this.placementBox = new PlacementBox(scene, this.placeOnWall ? "back" : "bottom"); this.placementComplete = false; this.lastTick = performance.now(); this.dispatchEvent({ type: "status", status: ARStatus.SESSION_STARTED }); } async stopPresenting() { if (!this.isPresenting) { return; } const cleanupPromise = new Promise((resolve) => { this.resolveCleanup = resolve; }); try { await this.currentSession.end(); await cleanupPromise; } catch (error) { console.warn("Error while trying to end WebXR AR session"); console.warn(error); this.postSessionCleanup(); } } get isPresenting() { return this.presentedScene != null; } get target() { return this.oldTarget; } updateTarget() { const scene = this.presentedScene; if (scene != null) { const target = scene.getTarget(); this.oldTarget.copy(target); if (this.placeOnWall) { target.z = scene.boundingBox.min.z; } else { target.y = scene.boundingBox.min.y; } scene.setTarget(target.x, target.y, target.z); } } postSessionCleanup() { const session = this.currentSession; if (session != null) { session.removeEventListener("selectstart", this.onSelectStart); session.removeEventListener("selectend", this.onSelectEnd); this.currentSession = null; } const scene = this.presentedScene; this._presentedScene = null; if (scene != null) { const { element } = scene; if (this.xrLight != null) { scene.remove(this.xrLight); this.xrLight.dispose(); this.xrLight = null; } scene.position.set(0, 0, 0); scene.scale.set(1, 1, 1); scene.setShadowOffset(0); const yaw = this.turntableRotation; if (yaw != null) { scene.yaw = yaw; } const intensity = this.oldShadowIntensity; if (intensity != null) { scene.setShadowIntensity(intensity); } scene.setEnvironmentAndSkybox(element[$currentEnvironmentMap], element[$currentBackground]); const point = this.oldTarget; scene.setTarget(point.x, point.y, point.z); scene.xrCamera = null; scene.element.removeEventListener("load", this.onUpdateScene); scene.orientHotspots(0); element.requestUpdate("cameraTarget"); element.requestUpdate("maxCameraOrbit"); element[$onResize](element.getBoundingClientRect()); requestAnimationFrame(() => { scene.element.dispatchEvent(new CustomEvent("camera-change", { detail: { source: ChangeSource.NONE } })); }); } this.renderer.height = 0; const exitButton = this.exitWebXRButtonContainer; if (exitButton != null) { exitButton.classList.remove("enabled"); exitButton.removeEventListener("click", this.onExitWebXRButtonContainerClick); this.exitWebXRButtonContainer = null; } const hitSource = this.transientHitTestSource; if (hitSource != null) { hitSource.cancel(); this.transientHitTestSource = null; } const hitSourceInitial = this.initialHitSource; if (hitSourceInitial != null) { hitSourceInitial.cancel(); this.initialHitSource = null; } if (this.placementBox != null) { this.placementBox.dispose(); this.placementBox = null; } this.lastTick = null; this.turntableRotation = null; this.oldShadowIntensity = null; this.frame = null; this.inputSource = null; this.overlay = null; if (this.resolveCleanup != null) { this.resolveCleanup(); } this.dispatchEvent({ type: "status", status: ARStatus.NOT_PRESENTING }); } updateView(view) { const scene = this.presentedScene; const xr = this.threeRenderer.xr; xr.updateCamera(camera); scene.xrCamera = xr.getCamera(); const { elements } = scene.getCamera().matrixWorld; scene.orientHotspots(Math.atan2(elements[1], elements[5])); if (!this.initialized) { this.placeInitially(); this.initialized = true; } if (view.requestViewportScale && view.recommendedViewportScale) { const scale = view.recommendedViewportScale; view.requestViewportScale(Math.max(scale, MIN_VIEWPORT_SCALE)); } const layer = xr.getBaseLayer(); if (layer != null) { const viewport = layer instanceof XRWebGLLayer ? layer.getViewport(view) : xr.getBinding().getViewSubImage(layer, view).viewport; this.threeRenderer.setViewport(viewport.x, viewport.y, viewport.width, viewport.height); } } placeInitially() { const scene = this.presentedScene; const { position, element } = scene; const xrCamera = scene.getCamera(); const { width, height } = this.overlay.getBoundingClientRect(); scene.setSize(width, height); xrCamera.projectionMatrixInverse.copy(xrCamera.projectionMatrix).invert(); const { theta, radius } = element.getCameraOrbit(); const cameraDirection = xrCamera.getWorldDirection(vector3); scene.yaw = Math.atan2(-cameraDirection.x, -cameraDirection.z) - theta; this.goalYaw = scene.yaw; position.copy(xrCamera.position).add(cameraDirection.multiplyScalar(radius)); this.updateTarget(); const target = scene.getTarget(); position.add(target).sub(this.oldTarget); this.goalPosition.copy(position); scene.setHotspotsVisibility(true); const { session } = this.frame; session.addEventListener("selectstart", this.onSelectStart); session.addEventListener("selectend", this.onSelectEnd); session.requestHitTestSourceForTransientInput({ profile: "generic-touchscreen" }).then((hitTestSource) => { this.transientHitTestSource = hitTestSource; }); } getTouchLocation() { const { axes } = this.inputSource.gamepad; let location = this.placementBox.getExpandedHit(this.presentedScene, axes[0], axes[1]); if (location != null) { vector3.copy(location).sub(this.presentedScene.getCamera().position); if (vector3.length() > MAX_DISTANCE) return null; } return location; } getHitPoint(hitResult) { const refSpace = this.threeRenderer.xr.getReferenceSpace(); const pose = hitResult.getPose(refSpace); if (pose == null) { return null; } const hitMatrix = matrix4.fromArray(pose.transform.matrix); if (this.placeOnWall === true) { this.goalYaw = Math.atan2(hitMatrix.elements[4], hitMatrix.elements[6]); } return hitMatrix.elements[5] > 0.75 !== this.placeOnWall ? hitPosition.setFromMatrixPosition(hitMatrix) : null; } moveToFloor(frame) { const hitSource = this.initialHitSource; if (hitSource == null) { return; } const hitTestResults = frame.getHitTestResults(hitSource); if (hitTestResults.length == 0) { return; } const hit = hitTestResults[0]; const hitPoint = this.getHitPoint(hit); if (hitPoint == null) { return; } this.placementBox.show = true; if (!this.isTranslating) { if (this.placeOnWall) { this.goalPosition.copy(hitPoint); } else { this.goalPosition.y = hitPoint.y; } } hitSource.cancel(); this.initialHitSource = null; this.dispatchEvent({ type: "status", status: ARStatus.OBJECT_PLACED }); } fingerPolar(fingers) { const fingerOne = fingers[0].inputSource.gamepad.axes; const fingerTwo = fingers[1].inputSource.gamepad.axes; const deltaX = fingerTwo[0] - fingerOne[0]; const deltaY = fingerTwo[1] - fingerOne[1]; const angle = Math.atan2(deltaY, deltaX); let deltaYaw = this.lastAngle - angle; if (deltaYaw > Math.PI) { deltaYaw -= 2 * Math.PI; } else if (deltaYaw < -Math.PI) { deltaYaw += 2 * Math.PI; } this.lastAngle = angle; return { separation: Math.sqrt(deltaX * deltaX + deltaY * deltaY), deltaYaw }; } processInput(frame) { const hitSource = this.transientHitTestSource; if (hitSource == null) { return; } if (!this.isTranslating && !this.isTwoFingering && !this.isRotating) { return; } const fingers = frame.getHitTestResultsForTransientInput(hitSource); const scene = this.presentedScene; const scale = scene.scale.x; if (this.isTwoFingering) { if (fingers.length < 2) { this.isTwoFingering = false; } else { const { separation, deltaYaw } = this.fingerPolar(fingers); if (this.placeOnWall === false) { this.goalYaw += deltaYaw; } if (scene.canScale) { const scale2 = separation / this.firstRatio; this.goalScale = scale2 < SCALE_SNAP_HIGH && scale2 > SCALE_SNAP_LOW ? 1 : scale2; } } return; } else if (fingers.length === 2) { this.isTranslating = false; this.isRotating = false; this.isTwoFingering = true; const { separation } = this.fingerPolar(fingers); this.firstRatio = separation / scale; return; } if (this.isRotating) { const angle = this.inputSource.gamepad.axes[0] * ROTATION_RATE; this.goalYaw += angle - this.lastAngle; this.lastAngle = angle; } else if (this.isTranslating) { fingers.forEach((finger) => { if (finger.inputSource !== this.inputSource) { return; } let hit = null; if (finger.results.length > 0) { hit = this.getHitPoint(finger.results[0]); } if (hit == null) { hit = this.getTouchLocation(); } if (hit == null) { return; } this.goalPosition.sub(this.lastDragPosition); if (this.placeOnWall === false) { const offset = hit.y - this.lastDragPosition.y; if (offset < 0) { this.placementBox.offsetHeight = offset / scale; this.presentedScene.setShadowOffset(offset); const cameraPosition = vector3.copy(scene.getCamera().position); const alpha = -offset / (cameraPosition.y - hit.y); cameraPosition.multiplyScalar(alpha); hit.multiplyScalar(1 - alpha).add(cameraPosition); } } this.goalPosition.add(hit); this.lastDragPosition.copy(hit); }); } } moveScene(delta) { const scene = this.presentedScene; const { position, yaw } = scene; const boundingRadius = scene.boundingSphere.radius; const goal = this.goalPosition; const oldScale = scene.scale.x; const box = this.placementBox; let source = ChangeSource.NONE; if (!goal.equals(position) || this.goalScale !== oldScale) { source = ChangeSource.USER_INTERACTION; let { x, y, z } = position; x = this.xDamper.update(x, goal.x, delta, boundingRadius); y = this.yDamper.update(y, goal.y, delta, boundingRadius); z = this.zDamper.update(z, goal.z, delta, boundingRadius); position.set(x, y, z); const newScale = this.scaleDamper.update(oldScale, this.goalScale, delta, 1); scene.scale.set(newScale, newScale, newScale); if (!this.isTranslating) { const offset = goal.y - y; if (this.placementComplete && this.placeOnWall === false) { box.offsetHeight = offset / newScale; scene.setShadowOffset(offset); } else if (offset === 0) { this.placementComplete = true; box.show = false; scene.setShadowIntensity(AR_SHADOW_INTENSITY); } } } box.updateOpacity(delta); scene.updateTarget(delta); scene.yaw = this.yawDamper.update(yaw, this.goalYaw, delta, Math.PI); scene.element.dispatchEvent(new CustomEvent("camera-change", { detail: { source } })); } onWebXRFrame(time, frame) { this.frame = frame; ++this.frames; const refSpace = this.threeRenderer.xr.getReferenceSpace(); const pose = frame.getViewerPose(refSpace); if (pose == null && this.tracking === true && this.frames > INIT_FRAMES) { this.tracking = false; this.dispatchEvent({ type: "tracking", status: ARTracking.NOT_TRACKING }); } const scene = this.presentedScene; if (pose == null || scene == null || !scene.element.loaded) { this.threeRenderer.clear(); return; } if (this.tracking === false) { this.tracking = true; this.dispatchEvent({ type: "tracking", status: ARTracking.TRACKING }); } let isFirstView = true; for (const view of pose.views) { this.updateView(view); if (isFirstView) { this.moveToFloor(frame); this.processInput(frame); const delta = time - this.lastTick; this.moveScene(delta); this.renderer.preRender(scene, time, delta); this.lastTick = time; scene.renderShadow(this.threeRenderer); } this.threeRenderer.render(scene, scene.getCamera()); isFirstView = false; } } } export { ARRenderer, ARStatus, ARTracking };