| - [DepthBufferPlugin](#depthbufferplugin) - Pre-rendering of depth buffer | - [DepthBufferPlugin](#depthbufferplugin) - Pre-rendering of depth buffer | ||||
| - [NormalBufferPlugin](#normalbufferplugin) - Pre-rendering of normal buffer | - [NormalBufferPlugin](#normalbufferplugin) - Pre-rendering of normal buffer | ||||
| - [GBufferPlugin](#gbufferplugin) - Pre-rendering of depth-normal and flags buffers in a single pass | - [GBufferPlugin](#gbufferplugin) - Pre-rendering of depth-normal and flags buffers in a single pass | ||||
| - [CanvasSnapshotPlugin](#canvassnapshotplugin) - Add support for taking snapshots of the canvas | |||||
| - [PickingPlugin](#pickingplugin) - Adds support for selecting objects in the viewer with user interactions and selection widgets | - [PickingPlugin](#pickingplugin) - Adds support for selecting objects in the viewer with user interactions and selection widgets | ||||
| - [TransformControlsPlugin](#transformcontrolsplugin) - Adds support for moving, rotating and scaling objects in the viewer with interactive widgets | - [TransformControlsPlugin](#transformcontrolsplugin) - Adds support for moving, rotating and scaling objects in the viewer with interactive widgets | ||||
| - [ContactShadowGroundPlugin](#contactshadowgroundplugin) - Adds a ground plane at runtime with contact shadows | |||||
| - [GLTFAnimationPlugin](#gltfanimationplugin) - Add support for playing and seeking gltf animations | - [GLTFAnimationPlugin](#gltfanimationplugin) - Add support for playing and seeking gltf animations | ||||
| - [PopmotionPlugin](#popmotionplugin) - Integrates with popmotion.io library for animation/tweening | - [PopmotionPlugin](#popmotionplugin) - Integrates with popmotion.io library for animation/tweening | ||||
| - [CameraViewPlugin](#cameraviewplugin) - Add support for saving, loading, animating, looping between camera views | - [CameraViewPlugin](#cameraviewplugin) - Add support for saving, loading, animating, looping between camera views | ||||
| // Use the normal target by accessing `normalTarget.texture`. | // Use the normal target by accessing `normalTarget.texture`. | ||||
| ``` | ``` | ||||
| ## GBufferPlugin | ## GBufferPlugin | ||||
| [//]: # (todo: image) | [//]: # (todo: image) | ||||
| const gBufferFlags = gBufferPlugin.flagsTexture; | const gBufferFlags = gBufferPlugin.flagsTexture; | ||||
| ``` | ``` | ||||
| ## CanvasSnapshotPlugin | |||||
| [//]: # (todo: image) | |||||
| [Example](https://threepipe.org/examples/#canvas-snapshot-plugin/) — | |||||
| [Source Code](./src/plugins/export/CanvasSnapshotPlugin.ts) — | |||||
| [API Reference](https://threepipe.org/docs/classes/CanvasSnapshotPlugin.html) | |||||
| Canvas Snapshot Plugin adds support for taking snapshots of the canvas and exporting them as images and data urls. It includes options to take snapshot of a region, mime type, quality render scale and scaling the output image. Check out the interface [CanvasSnapshotOptions](https://threepipe.org/docs/interfaces/CanvasSnapshotOptions.html) for more details. | |||||
| ```typescript | |||||
| import {ThreeViewer, CanvasSnapshotPlugin} from 'threepipe' | |||||
| const viewer = new ThreeViewer({...}) | |||||
| const snapshotPlugin = viewer.addPluginSync(new CanvasSnapshotPlugin()) | |||||
| // download a snapshot. | |||||
| await snapshotPlugin.downloadSnapshot('image.webp', { // all parameters are optional | |||||
| scale: 1, // scale the final image | |||||
| timeout: 0, // wait before taking the snapshot, in ms | |||||
| quality: 0.9, // quality of the image (0-1) only for jpeg and webp | |||||
| displayPixelRatio: 2, // render scale | |||||
| mimeType: 'image/webp', // mime type of the image | |||||
| waitForProgressive: true, // wait for progressive rendering to finish (ProgressivePlugin). true by default | |||||
| rect: { // region to take snapshot. eg. crop center of the canvas | |||||
| height: viewer.canvas.clientHeight / 2, | |||||
| width: viewer.canvas.clientWidth / 2, | |||||
| x: viewer.canvas.clientWidth / 4, | |||||
| y: viewer.canvas.clientHeight / 4, | |||||
| }, | |||||
| }) | |||||
| // get data url (string) | |||||
| const dataUrl = await snapshotPlugin.getDataUrl({ // all parameters are optional | |||||
| displayPixelRatio: 2, // render scale | |||||
| mimeType: 'image/webp', // mime type of the image | |||||
| }) | |||||
| // get File | |||||
| const file = await snapshotPlugin.getFile('file.jpeg', { // all parameters are optional | |||||
| mimeType: 'image/jpeg', // mime type of the image | |||||
| }) | |||||
| ``` | |||||
| ## PickingPlugin | ## PickingPlugin | ||||
| [//]: # (todo: image) | [//]: # (todo: image) | ||||
| [Example](https://threepipe.org/examples/#transform-controls-plugin/) — | [Example](https://threepipe.org/examples/#transform-controls-plugin/) — | ||||
| [Source Code](./src/plugins/interaction/TransformControlsPlugin.ts) — | [Source Code](./src/plugins/interaction/TransformControlsPlugin.ts) — | ||||
| [API Reference](https://threepipe.org/docs/classes/TransformControlsPlugin.html) | |||||
| [API Reference](https://threepipe.org/docs/classes/TransformControlsPlugin.html) | |||||
| Transform Controls Plugin adds support for moving, rotating and scaling objects in the viewer with interactive widgets. | Transform Controls Plugin adds support for moving, rotating and scaling objects in the viewer with interactive widgets. | ||||
| console.log(transfromControlsPlugin.transformControls) | console.log(transfromControlsPlugin.transformControls) | ||||
| ``` | ``` | ||||
| ## ContactShadowGroundPlugin | |||||
| [//]: # (todo: image) | |||||
| [Example](https://threepipe.org/examples/#contact-shadow-ground-plugin/) — | |||||
| [Source Code](./src/plugins/extras/ContactShadowGroundPlugin.ts) — | |||||
| [API Reference](https://threepipe.org/docs/classes/ContactShadowGroundPlugin.html) | |||||
| Contact Shadow Ground Plugin adds a ground plane with three.js contact shadows to the viewer scene. | |||||
| The plane is added to the scene root at runtime and not saved with scene export. Instead the plugin settings are saved with the scene. | |||||
| It inherits from the base class [BaseGroundPlugin](https://threepipe.org/docs/classes/BaseGroundPlugin.html) which provides generic ground plane functionality. Check the source code for more details. With the property `autoAdjustTransform`, the ground plane is automatically adjusted based on the bounding box of the scene. | |||||
| ```typescript | |||||
| import {ThreeViewer, ContactShadowGroundPlugin} from 'threepipe' | |||||
| const viewer = new ThreeViewer({...}) | |||||
| viewer.addPluginSync(new ContactShadowGroundPlugin()) | |||||
| ``` | |||||
| ## GLTFAnimationPlugin | ## GLTFAnimationPlugin | ||||
| <!DOCTYPE html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <meta charset="UTF-8"> | |||||
| <title>Canvas Snapshot Plugin</title> | |||||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||||
| <!-- Import maps polyfill --> | |||||
| <!-- Remove this when import maps will be widely supported --> | |||||
| <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> | |||||
| <script type="importmap"> | |||||
| { | |||||
| "imports": { | |||||
| "threepipe": "./../../dist/index.mjs" | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style id="example-style"> | |||||
| html, body, #canvas-container, #mcanvas { | |||||
| width: 100%; | |||||
| height: 100%; | |||||
| margin: 0; | |||||
| overflow: hidden; | |||||
| } | |||||
| </style> | |||||
| <script type="module" src="../examples-utils/simple-code-preview.mjs"></script> | |||||
| <script id="example-script" type="module" src="./script.js" data-scripts="./script.ts;./script.js"></script> | |||||
| </head> | |||||
| <body> | |||||
| <div id="canvas-container"> | |||||
| <canvas id="mcanvas"></canvas> | |||||
| </div> | |||||
| </body> |
| import {_testFinish, CanvasSnapshotPlugin, isWebpExportSupported, ThreeViewer} from 'threepipe' | |||||
| import {createSimpleButtons} from '../examples-utils/simple-bottom-buttons.js' | |||||
| const viewer = new ThreeViewer({ | |||||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||||
| msaa: true, | |||||
| renderScale: 'auto', | |||||
| }) | |||||
| async function init() { | |||||
| const snapshotPlugin = viewer.addPluginSync(new CanvasSnapshotPlugin()) | |||||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||||
| await viewer.load('https://threejs.org/examples/models/gltf/kira.glb', { | |||||
| autoCenter: true, | |||||
| autoScale: true, | |||||
| }) | |||||
| createSimpleButtons({ | |||||
| ['Download snapshot (rect png)']: async(btn: HTMLButtonElement) => { | |||||
| btn.disabled = true | |||||
| await snapshotPlugin.downloadSnapshot('snapshot.png', { | |||||
| scale: 1, // scale the final image | |||||
| displayPixelRatio: 2, // render scale | |||||
| mimeType: 'image/png', // mime type of the image | |||||
| rect: { // region to take snapshot. Crop center of the canvas | |||||
| height: viewer.canvas.clientHeight / 2, | |||||
| width: viewer.canvas.clientWidth / 2, | |||||
| x: viewer.canvas.clientWidth / 4, | |||||
| y: viewer.canvas.clientHeight / 4, | |||||
| }, | |||||
| }) | |||||
| btn.disabled = false | |||||
| }, | |||||
| ['Download snapshot (jpeg)']: async(btn: HTMLButtonElement) => { | |||||
| btn.disabled = true | |||||
| await snapshotPlugin.downloadSnapshot('snapshot.jpeg', { | |||||
| mimeType: 'image/jpeg', // mime type of the image | |||||
| quality: 0.9, // quality of the image (0-1) only for jpeg and webp | |||||
| displayPixelRatio: 2, // render scale | |||||
| }) | |||||
| btn.disabled = false | |||||
| }, | |||||
| ['Download snapshot (webp)']: async(btn: HTMLButtonElement) => { | |||||
| btn.disabled = true | |||||
| if (!isWebpExportSupported()) { | |||||
| alert('WebP export is not supported in this browser, try the latest version of chrome, firefox or edge.') | |||||
| btn.disabled = false | |||||
| return | |||||
| } | |||||
| await snapshotPlugin.downloadSnapshot('snapshot.webp', { | |||||
| mimeType: 'image/webp', // mime type of the image | |||||
| scale: 1, // scale the final image | |||||
| quality: 0.9, // quality of the image (0-1) only for jpeg and webp | |||||
| displayPixelRatio: 2, // render scale | |||||
| }) | |||||
| btn.disabled = false | |||||
| }, | |||||
| }) | |||||
| } | |||||
| init().then(_testFinish) |
| <!DOCTYPE html> | |||||
| <html lang="en"> | |||||
| <head> | |||||
| <meta charset="UTF-8"> | |||||
| <title>Contact Shadow Ground Plugin</title> | |||||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||||
| <!-- Import maps polyfill --> | |||||
| <!-- Remove this when import maps will be widely supported --> | |||||
| <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> | |||||
| <script type="importmap"> | |||||
| { | |||||
| "imports": { | |||||
| "threepipe": "./../../dist/index.mjs", | |||||
| "@threepipe/plugin-tweakpane": "./../../plugins/tweakpane/dist/index.mjs" | |||||
| } | |||||
| } | |||||
| </script> | |||||
| <style id="example-style"> | |||||
| html, body, #canvas-container, #mcanvas { | |||||
| width: 100%; | |||||
| height: 100%; | |||||
| margin: 0; | |||||
| overflow: hidden; | |||||
| } | |||||
| </style> | |||||
| <script type="module" src="../examples-utils/simple-code-preview.mjs"></script> | |||||
| <script id="example-script" type="module" src="./script.js" data-scripts="./script.ts;./script.js"></script> | |||||
| </head> | |||||
| <body> | |||||
| <div id="canvas-container"> | |||||
| <canvas id="mcanvas"></canvas> | |||||
| </div> | |||||
| </body> |
| import {_testFinish, ContactShadowGroundPlugin, IObject3D, ThreeViewer} from 'threepipe' | |||||
| import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane' | |||||
| async function init() { | |||||
| const viewer = new ThreeViewer({ | |||||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||||
| }) | |||||
| viewer.addPluginSync(ContactShadowGroundPlugin) | |||||
| await Promise.all([ | |||||
| viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr'), | |||||
| viewer.load<IObject3D>('https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf'), | |||||
| ]) | |||||
| const ui = viewer.addPluginSync(new TweakpaneUiPlugin(true)) | |||||
| ui.setupPluginUi(ContactShadowGroundPlugin, {expanded: true}) | |||||
| } | |||||
| init().then(_testFinish) |
| msaa: true, | msaa: true, | ||||
| }) | }) | ||||
| // Note: see also: CanvasSnapshotPlugin | |||||
| async function init() { | async function init() { | ||||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') |
| </ul> | </ul> | ||||
| <h2 class="category">Export</h2> | <h2 class="category">Export</h2> | ||||
| <ul> | <ul> | ||||
| <li><a href="./canvas-snapshot-plugin/">Canvas Snapshot Plugin<br/>(Image Snapshot) </a></li> | |||||
| <li><a href="./image-snapshot-export/">PNG, JPEG, WEBP Export<br/>(Image Snapshot) </a></li> | <li><a href="./image-snapshot-export/">PNG, JPEG, WEBP Export<br/>(Image Snapshot) </a></li> | ||||
| <li><a href="./render-target-export/">EXR, PNG, JPEG, WEBP Export<br/>(Render Target Export) </a></li> | <li><a href="./render-target-export/">EXR, PNG, JPEG, WEBP Export<br/>(Render Target Export) </a></li> | ||||
| <li><a href="./glb-export/">GLB Export </a></li> | <li><a href="./glb-export/">GLB Export </a></li> | ||||
| </ul> | </ul> | ||||
| <h2 class="category">Utils</h2> | <h2 class="category">Utils</h2> | ||||
| <ul> | <ul> | ||||
| <li><a href="./contact-shadow-ground-plugin/">Contact Shadow Ground Plugin</a></li> | |||||
| <li><a href="./hdri-ground-plugin/">HDRi Ground Plugin <br/>(Projected Skybox)</a></li> | <li><a href="./hdri-ground-plugin/">HDRi Ground Plugin <br/>(Projected Skybox)</a></li> | ||||
| <li><a href="./render-target-preview/">Render Target Preview Plugin </a></li> | <li><a href="./render-target-preview/">Render Target Preview Plugin </a></li> | ||||
| <li><a href="./object3d-generator-plugin/">Object3D Generator Plugin <br/>(Lights, Cameras)</a></li> | <li><a href="./object3d-generator-plugin/">Object3D Generator Plugin <br/>(Lights, Cameras)</a></li> |
| import { | import { | ||||
| _testFinish, | _testFinish, | ||||
| CameraViewPlugin, | CameraViewPlugin, | ||||
| CanvasSnapshotPlugin, | |||||
| ChromaticAberrationPlugin, | ChromaticAberrationPlugin, | ||||
| ClearcoatTintPlugin, | ClearcoatTintPlugin, | ||||
| ContactShadowGroundPlugin, | |||||
| CustomBumpMapPlugin, | CustomBumpMapPlugin, | ||||
| DepthBufferPlugin, | DepthBufferPlugin, | ||||
| DropzonePlugin, | DropzonePlugin, | ||||
| const viewer = new ThreeViewer({ | const viewer = new ThreeViewer({ | ||||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | ||||
| renderScale: 'auto', | |||||
| msaa: true, | msaa: true, | ||||
| rgbm: true, | rgbm: true, | ||||
| zPrepass: false, // set it to true if you only have opaque objects in the scene to get better performance. | zPrepass: false, // set it to true if you only have opaque objects in the scene to get better performance. | ||||
| Object3DWidgetsPlugin, | Object3DWidgetsPlugin, | ||||
| Object3DGeneratorPlugin, | Object3DGeneratorPlugin, | ||||
| GaussianSplattingPlugin, | GaussianSplattingPlugin, | ||||
| ContactShadowGroundPlugin, | |||||
| CanvasSnapshotPlugin, | |||||
| ...extraImportPlugins, | ...extraImportPlugins, | ||||
| ]) | ]) | ||||
| editor.loadPlugins({ | editor.loadPlugins({ | ||||
| ['Viewer']: [ViewerUiConfigPlugin, SceneUiConfigPlugin, DropzonePlugin, FullScreenPlugin, TweakpaneUiPlugin], | ['Viewer']: [ViewerUiConfigPlugin, SceneUiConfigPlugin, DropzonePlugin, FullScreenPlugin, TweakpaneUiPlugin], | ||||
| ['Scene']: [ContactShadowGroundPlugin], | |||||
| ['Interaction']: [HierarchyUiPlugin, TransformControlsPlugin, PickingPlugin, Object3DGeneratorPlugin, GeometryGeneratorPlugin, EditorViewWidgetPlugin, Object3DWidgetsPlugin], | ['Interaction']: [HierarchyUiPlugin, TransformControlsPlugin, PickingPlugin, Object3DGeneratorPlugin, GeometryGeneratorPlugin, EditorViewWidgetPlugin, Object3DWidgetsPlugin], | ||||
| ['GBuffer']: [GBufferPlugin, DepthBufferPlugin, NormalBufferPlugin], | ['GBuffer']: [GBufferPlugin, DepthBufferPlugin, NormalBufferPlugin], | ||||
| ['Post-processing']: [TonemapPlugin, ProgressivePlugin, FrameFadePlugin, VignettePlugin, ChromaticAberrationPlugin, FilmicGrainPlugin], | ['Post-processing']: [TonemapPlugin, ProgressivePlugin, FrameFadePlugin, VignettePlugin, ChromaticAberrationPlugin, FilmicGrainPlugin], | ||||
| ['Export']: [CanvasSnapshotPlugin], | |||||
| ['Animation']: [GLTFAnimationPlugin, CameraViewPlugin], | ['Animation']: [GLTFAnimationPlugin, CameraViewPlugin], | ||||
| ['Extras']: [HDRiGroundPlugin, Rhino3dmLoadPlugin, ClearcoatTintPlugin, FragmentClippingExtensionPlugin, NoiseBumpMaterialPlugin, CustomBumpMapPlugin, VirtualCamerasPlugin], | ['Extras']: [HDRiGroundPlugin, Rhino3dmLoadPlugin, ClearcoatTintPlugin, FragmentClippingExtensionPlugin, NoiseBumpMaterialPlugin, CustomBumpMapPlugin, VirtualCamerasPlugin], | ||||
| ['Debug']: [RenderTargetPreviewPlugin], | ['Debug']: [RenderTargetPreviewPlugin], |
| import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||||
| import {IGeometry, iGeometryCommons, IMaterial, ISceneEvent, Mesh2, PhysicalMaterial, UnlitMaterial} from '../../core' | |||||
| import {BufferAttribute, Euler, InterleavedBufferAttribute, PlaneGeometry, Vector3} from 'three' | |||||
| import {onChange, onChange2, serialize} from 'ts-browser-helpers' | |||||
| import {OrbitControls3} from '../../three' | |||||
| import {uiConfig, uiFolderContainer, uiNumber, uiToggle} from 'uiconfig.js' | |||||
| @uiFolderContainer('Ground') | |||||
| export class BaseGroundPlugin<TEvent extends string = ''> extends AViewerPluginSync<TEvent> { | |||||
| public static readonly PluginType: string = 'BaseGroundPlugin' | |||||
| get enabled() { | |||||
| return this.visible | |||||
| } | |||||
| set enabled(value) { | |||||
| this.visible = value | |||||
| } | |||||
| protected _geometry: IGeometry&PlaneGeometry | |||||
| protected _mesh: Mesh2<IGeometry&PlaneGeometry, IMaterial> | |||||
| private _transformNeedRefresh = true | |||||
| constructor() { | |||||
| super() | |||||
| this._refreshMaterial = this._refreshMaterial.bind(this) | |||||
| this._refreshTransform = this._refreshTransform.bind(this) | |||||
| this._refreshCameraLimits = this._refreshCameraLimits.bind(this) | |||||
| this.refresh = this.refresh.bind(this) | |||||
| this._refresh2 = this._refresh2.bind(this) | |||||
| this._onSceneUpdate = this._onSceneUpdate.bind(this) | |||||
| this._preRender = this._preRender.bind(this) | |||||
| this._postFrame = this._postFrame.bind(this) | |||||
| this._geometry = iGeometryCommons.upgradeGeometry.call(new PlaneGeometry(1, 1, 1, 1)) | |||||
| this._geometry.attributes.uv2 = (this._geometry.attributes.uv as any as BufferAttribute | InterleavedBufferAttribute).clone() | |||||
| this._geometry.attributes.uv2.needsUpdate = true | |||||
| this._mesh = this._createMesh() | |||||
| this.refresh() | |||||
| } | |||||
| @uiToggle('Visible') | |||||
| @onChange(BaseGroundPlugin.prototype.refreshTransform) | |||||
| @serialize() visible = true | |||||
| @uiNumber('Size') | |||||
| @onChange2(BaseGroundPlugin.prototype._onSceneUpdate) | |||||
| @serialize() size = 8 | |||||
| @uiNumber('Height (yOffset)') | |||||
| @onChange2(BaseGroundPlugin.prototype._onSceneUpdate) | |||||
| @serialize() yOffset = 0 | |||||
| @uiToggle('Render to Depth') | |||||
| @onChange(BaseGroundPlugin.prototype._refresh2) | |||||
| @serialize() renderToDepth = true | |||||
| /** | |||||
| * If false, the ground will not be tonemapped in post processing. | |||||
| * note: this will only work when {@link GBufferPlugin} is being used. Also needs {@link renderToDepth} to be true. | |||||
| */ | |||||
| @uiToggle('Tonemap Ground') | |||||
| @onChange(BaseGroundPlugin.prototype._refresh2) | |||||
| @serialize() tonemapGround = true | |||||
| /** | |||||
| * If true, the camera will be limited to not go below the ground. | |||||
| * note: this will only work when {@link OrbitControls3} or three.js OrbitControls are being used. | |||||
| */ | |||||
| @uiToggle('Limit Camera Above Ground') | |||||
| @onChange(BaseGroundPlugin.prototype._refreshCameraLimits) | |||||
| @serialize() limitCameraAboveGround = false | |||||
| @uiToggle('Auto Adjust Transform') | |||||
| @onChange(BaseGroundPlugin.prototype.refreshTransform) | |||||
| @serialize() autoAdjustTransform = true | |||||
| @serialize('material') | |||||
| @uiConfig() | |||||
| protected _material?: PhysicalMaterial | |||||
| onAdded(viewer: ThreeViewer): void { | |||||
| super.onAdded(viewer) | |||||
| // if (viewer.getPlugin('TweakpaneUi')) console.error('TweakpaneUiPlugin must be added after Ground Plugin') | |||||
| viewer.scene.addObject(this._mesh, {addToRoot: true}) | |||||
| viewer.scene.addEventListener('sceneUpdate', this._onSceneUpdate) // todo: refresh when update... | |||||
| viewer.scene.addEventListener('addSceneObject', this._onSceneUpdate) | |||||
| viewer.addEventListener('preRender', this._preRender) | |||||
| viewer.addEventListener('postFrame', this._postFrame) | |||||
| this.refresh() | |||||
| } | |||||
| onRemove(viewer: ThreeViewer): void { | |||||
| this._mesh?.dispose(true) | |||||
| this._removeMaterial() | |||||
| viewer.scene.removeEventListener('sceneUpdate', this._onSceneUpdate) | |||||
| viewer.scene.removeEventListener('addSceneObject', this._onSceneUpdate) | |||||
| viewer.removeEventListener('postFrame', this._postFrame) | |||||
| viewer.removeEventListener('preRender', this._preRender) | |||||
| return super.onRemove(viewer) | |||||
| } | |||||
| protected _postFrame() { | |||||
| if (this._transformNeedRefresh) this._refreshTransform() | |||||
| if (!this._viewer) return | |||||
| } | |||||
| protected _preRender() { | |||||
| if (!this._viewer) return | |||||
| } | |||||
| dispose(): void { | |||||
| this._removeMaterial() | |||||
| this._geometry.dispose() | |||||
| this._material?.dispose() // todo | |||||
| this._mesh?.dispose?.() | |||||
| super.dispose() | |||||
| } | |||||
| protected _removeMaterial() { | |||||
| if (!this._material) return | |||||
| // this._manager?.materials?.unregisterMaterial(this._material) | |||||
| this._material.userData.renderToDepth = this._material.userData.__renderToDepth | |||||
| this._material.userData.__renderToDepth = undefined | |||||
| // todo reset gBufferData.tonemapEnabled also | |||||
| this._material = undefined | |||||
| } | |||||
| protected _onSceneUpdate(event?: ISceneEvent) { | |||||
| if (event?.geometryChanged === false) return | |||||
| if (event?.updateGround !== false) | |||||
| this.refreshTransform() | |||||
| } | |||||
| /** | |||||
| * Extra flag for plugins to disable transform refresh like when animating or dragging | |||||
| */ | |||||
| enableRefreshTransform = true | |||||
| refreshTransform(): void { | |||||
| if (!this.enableRefreshTransform) return | |||||
| this._transformNeedRefresh = true | |||||
| } | |||||
| public refresh(): void { | |||||
| if (!this._viewer) return | |||||
| this._refreshMaterial() | |||||
| this.refreshTransform() | |||||
| this._refreshCameraLimits() | |||||
| } | |||||
| // because of inheritance breaks onChange | |||||
| private _refresh2(): void { | |||||
| this.refresh() | |||||
| } | |||||
| private _cameraLimitsSet = false | |||||
| private _cameraLastMaxPolarAngle = Math.PI | |||||
| private _refreshCameraLimits() { | |||||
| const orbit = this._viewer?.scene.mainCamera.controls as OrbitControls3 | |||||
| if (!orbit) return | |||||
| if (orbit.maxPolarAngle === undefined) { | |||||
| console.warn('refreshCameraLimits only available with orbit controls.') | |||||
| return | |||||
| } | |||||
| if (this.limitCameraAboveGround) { | |||||
| if (!this._cameraLimitsSet) this._cameraLastMaxPolarAngle = orbit.maxPolarAngle | |||||
| orbit.maxPolarAngle = Math.PI / 2 | |||||
| this._cameraLimitsSet = true | |||||
| } else if (this._cameraLimitsSet) { | |||||
| orbit.maxPolarAngle = this._cameraLastMaxPolarAngle | |||||
| this._cameraLimitsSet = false | |||||
| } | |||||
| } | |||||
| protected _refreshTransform() { | |||||
| if (!this._mesh) return | |||||
| if (!this._viewer) return | |||||
| let updated = false | |||||
| if (this.visible !== this._mesh.visible) { | |||||
| this._mesh.visible = this.visible | |||||
| updated = true | |||||
| } | |||||
| if (this.isDisabled()) { | |||||
| if (updated) this._viewer?.scene.setDirty() | |||||
| return | |||||
| } | |||||
| if (this.autoAdjustTransform) { | |||||
| this._mesh.userData.bboxVisible = false | |||||
| const bbox = this._viewer.scene.getBounds(true) | |||||
| this._mesh.userData.bboxVisible = true | |||||
| const v = bbox.getCenter( | |||||
| new Vector3()).sub(new Vector3(0, | |||||
| bbox.getSize(new Vector3()).y / 2 + this.yOffset, | |||||
| 0)) | |||||
| updated = updated || v.clone().sub(this._mesh.position).length() > 0.0001 | |||||
| if (updated) { | |||||
| this._mesh.position.copy(v) | |||||
| } | |||||
| } | |||||
| updated = updated || Math.abs(this._mesh.scale.x - this.size) > 0.0001 | |||||
| // todo: check rotation, someone could externally change it | |||||
| if (updated) { | |||||
| this._mesh.scale.setScalar(this.size) | |||||
| // this._mesh.lookAt(new Vector3().fromArray(this._options.up)) | |||||
| this._mesh.setRotationFromEuler(new Euler(-Math.PI / 2., 0, this._mesh.rotation.z)) | |||||
| this._mesh.matrixWorldNeedsUpdate = true | |||||
| this._mesh.setDirty() | |||||
| // this._viewer.scene.setDirty() | |||||
| } | |||||
| this._transformNeedRefresh = false | |||||
| } | |||||
| protected _createMesh(mesh?: Mesh2<IGeometry&PlaneGeometry, IMaterial>): Mesh2<IGeometry&PlaneGeometry, IMaterial> { | |||||
| if (!mesh) mesh = new Mesh2(this._geometry, new UnlitMaterial()) | |||||
| else mesh.geometry = this._geometry | |||||
| if (mesh) { | |||||
| mesh.userData.physicsMass = 0 | |||||
| mesh.userData.userSelectable = false | |||||
| mesh.castShadow = true | |||||
| mesh.receiveShadow = true | |||||
| mesh.name = 'Ground Plane' | |||||
| } | |||||
| return mesh | |||||
| } | |||||
| protected _createMaterial(material?: PhysicalMaterial): PhysicalMaterial { | |||||
| if (!material) material = new PhysicalMaterial({ | |||||
| name: 'BaseGroundMaterial', | |||||
| color: 0xffffff, | |||||
| }) | |||||
| material.userData.runtimeMaterial = true | |||||
| return material | |||||
| } | |||||
| protected _refreshMaterial(): boolean { | |||||
| if (!this._viewer) return false | |||||
| if (this.isDisabled()) return false | |||||
| const mat = this._material ?? this._createMaterial() | |||||
| const isNewMaterial = mat !== this._material | |||||
| if (isNewMaterial) { // new material | |||||
| this._removeMaterial() | |||||
| this._material = mat | |||||
| const id = this._material?.uuid | |||||
| if (!id) console.warn('No material found for ground') | |||||
| this._viewer.scene.setDirty() | |||||
| if (this._mesh && this._material) { | |||||
| this._material.roughness = 0.2 | |||||
| this._material.metalness = 0.5 | |||||
| this._mesh.material = this._material // for update event handlers. | |||||
| } | |||||
| } | |||||
| if (this._material) { | |||||
| if (this._material.userData.__renderToDepth === undefined) { | |||||
| this._material.userData.__renderToDepth = this._material.userData.renderToDepth | |||||
| } | |||||
| if (this._material.userData.renderToDepth !== this.renderToDepth) { | |||||
| this._material.userData.renderToDepth = this.renderToDepth // required to work with SSR, SSAO etc when the ground is transparent / transmissive | |||||
| } | |||||
| if (!this._material.userData.gBufferData) this._material.userData.gBufferData = {} | |||||
| if (this._material.userData.gBufferData.__tonemapEnabled === undefined) { | |||||
| this._material.userData.gBufferData.__tonemapEnabled = this._material.userData.gBufferData.tonemapEnabled | |||||
| } | |||||
| if (this._material.userData.gBufferData.tonemapEnabled !== this.tonemapGround) { | |||||
| this._material.userData.gBufferData.tonemapEnabled = this.tonemapGround | |||||
| } | |||||
| this._material.userData.ssaoDisabled = true | |||||
| this._material.userData.sscsDisabled = true | |||||
| // if (this._material.userData.__postTonemap === undefined) { | |||||
| // this._material.userData.__postTonemap = this._material.userData.postTonemap | |||||
| // } | |||||
| // if (this._material.userData.postTonemap !== this.tonemapGround) { | |||||
| // this._material.userData.postTonemap = this.tonemapGround | |||||
| // } | |||||
| } | |||||
| this._viewer.setDirty(this) // todo: something else also? | |||||
| return isNewMaterial | |||||
| } | |||||
| get material() { | |||||
| return this._material | |||||
| } | |||||
| get mesh() { | |||||
| return this._mesh | |||||
| } | |||||
| fromJSON(data: any, meta?: any): this | null { | |||||
| if (data.options) { | |||||
| console.error('todo: support old webgi v0 file') | |||||
| } | |||||
| if (!super.fromJSON(data, meta)) return null | |||||
| // if (this._material && this._material.transmission >= 0.01) this._material.transparent = true | |||||
| this.refresh() | |||||
| // Note: baked shadow reset is done in ShadowMapBaker.fromJSON | |||||
| return this | |||||
| } | |||||
| } |
| import {serialize, timeout} from 'ts-browser-helpers' | |||||
| import {AViewerPluginSync} from '../../viewer' | |||||
| import {uiButton, uiConfig, uiFolderContainer, uiInput} from 'uiconfig.js' | |||||
| import {CanvasSnapshot, CanvasSnapshotOptions} from '../../utils/canvas-snapshot' | |||||
| import {ProgressivePlugin} from '../pipeline/ProgressivePlugin' | |||||
| @uiFolderContainer('Canvas Snapshot (Image Export)') | |||||
| export class CanvasSnapshotPlugin extends AViewerPluginSync<''> { | |||||
| static readonly PluginType = 'CanvasSnapshotPlugin' | |||||
| enabled = true | |||||
| constructor() { | |||||
| super() | |||||
| this.downloadSnapshot = this.downloadSnapshot.bind(this) | |||||
| this.getDataUrl({}) | |||||
| } | |||||
| /** | |||||
| * Returns a File object with screenshot of the viewer canvas | |||||
| * @param filename default is {@link CanvasSnapshotPlugin.filename} | |||||
| * @param options waitForProgressive: wait for progressive rendering to finish, default: true | |||||
| */ | |||||
| async getFile(filename?: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {waitForProgressive: true}): Promise<File|undefined> { | |||||
| options.getDataUrl = false | |||||
| return await this._getFile(filename || this.filename, options) as File | |||||
| } | |||||
| /** | |||||
| * Returns a data url of the screenshot of the viewer canvas | |||||
| * @param options waitForProgressive: wait for progressive rendering to finish, default: true | |||||
| */ | |||||
| async getDataUrl(options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {}): Promise<string> { | |||||
| options.getDataUrl = true | |||||
| return await this._getFile('', options) as string ?? '' | |||||
| } | |||||
| private async _getFile(filename: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {}): Promise<File|string|undefined> { | |||||
| const viewer = this._viewer | |||||
| const canvas = this._viewer?.canvas | |||||
| if (!viewer || !canvas) return undefined | |||||
| const dpr = viewer.renderManager.renderScale | |||||
| if (options.displayPixelRatio !== undefined && options.displayPixelRatio !== dpr) { | |||||
| viewer.renderManager.renderScale = options.displayPixelRatio | |||||
| } | |||||
| if (options.timeout) await timeout(options.timeout) | |||||
| const progressive = viewer.getPlugin(ProgressivePlugin) | |||||
| if (options.waitForProgressive !== false && progressive) { | |||||
| // todo: disable interactions and all so that frameCount is not affected | |||||
| await new Promise<void>((res)=>{ | |||||
| const listener = () => { | |||||
| if (!progressive.isConverged(true)) return | |||||
| viewer.removeEventListener('postFrame', listener) | |||||
| res() | |||||
| } | |||||
| viewer.addEventListener('postFrame', listener) | |||||
| }) | |||||
| } else await viewer.doOnce('postFrame') | |||||
| options.displayPixelRatio = 1 | |||||
| const rect = options.rect | |||||
| if (rect && viewer.renderManager.renderScale !== 1) { | |||||
| options.rect = { | |||||
| ...rect, | |||||
| x: rect.x * viewer.renderManager.renderScale, | |||||
| y: rect.y * viewer.renderManager.renderScale, | |||||
| width: rect.width * viewer.renderManager.renderScale, | |||||
| height: rect.height * viewer.renderManager.renderScale, | |||||
| } | |||||
| } | |||||
| const file = await CanvasSnapshot.GetFile(canvas, filename, options) | |||||
| options.rect = rect | |||||
| options.displayPixelRatio = viewer.renderManager.renderScale | |||||
| viewer.renderManager.renderScale = dpr | |||||
| return file | |||||
| } | |||||
| @uiInput('Filename') | |||||
| @serialize() | |||||
| filename = 'snapshot.png' | |||||
| /** | |||||
| * Only for {@link downloadSnapshot} and functions using that | |||||
| */ | |||||
| @uiConfig() | |||||
| @serialize() | |||||
| defaultOptions: CanvasSnapshotOptions&{waitForProgressive?: boolean} = { | |||||
| waitForProgressive: true, | |||||
| displayPixelRatio: window.devicePixelRatio, | |||||
| scale: 1, | |||||
| timeout: 0, | |||||
| quality: 0.9, | |||||
| } | |||||
| @uiButton('Download .png') | |||||
| async downloadSnapshot(filename?: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {waitForProgressive: true}): Promise<void> { | |||||
| if (!this._viewer) return | |||||
| if (!options.mimeType && !filename) this.filename = this.filename.split('.').slice(0, -1).join('.') + '.png' | |||||
| const file = await this.getFile(filename, {...this.defaultOptions, ...options}) | |||||
| if (file) await this._viewer.exportBlob(file, file.name) | |||||
| } | |||||
| @uiButton('Download .jpeg') | |||||
| protected async _downloadJpeg(): Promise<void> { | |||||
| this.filename = this.filename.split('.').slice(0, -1).join('.') + '.jpeg' | |||||
| return this.downloadSnapshot(undefined, {mimeType: 'image/jpeg'}) | |||||
| } | |||||
| @uiButton('Download .webp') | |||||
| protected async _downloadWebp(): Promise<void> { | |||||
| this.filename = this.filename.split('.').slice(0, -1).join('.') + '.webp' | |||||
| return this.downloadSnapshot(undefined, {mimeType: 'image/webp'}) | |||||
| } | |||||
| } | |||||
| /** | |||||
| * @deprecated - use {@link CanvasSnapshotPlugin} | |||||
| */ | |||||
| export class CanvasSnipperPlugin extends CanvasSnapshotPlugin { | |||||
| static readonly PluginType: any = 'CanvasSnipper' | |||||
| constructor() { | |||||
| super() | |||||
| console.warn('CanvasSnipperPlugin is deprecated, use CanvasSnapshotPlugin') | |||||
| } | |||||
| } |
| import {AViewerPluginSync} from '../../viewer' | |||||
| import {downloadBlob} from 'ts-browser-helpers' | |||||
| export class FileTransferPlugin extends AViewerPluginSync<'transferFile'> { | |||||
| enabled = true | |||||
| static readonly PluginType = 'FileTransferPlugin' | |||||
| toJSON: any = undefined | |||||
| async exportFile(file: File|Blob, name?: string) { | |||||
| name = name || (file as File).name || 'file_export' | |||||
| this.dispatchEvent({type: 'transferFile', path: name, state: 'exporting'}) | |||||
| await this.actions.exportFile(file, name, ({state, progress})=>{ | |||||
| this.dispatchEvent({type: 'transferFile', path: name, state: state ?? 'exporting', progress}) | |||||
| }) | |||||
| this.dispatchEvent({type: 'transferFile', path: name, state: 'done'}) | |||||
| } | |||||
| readonly defaultActions = { | |||||
| exportFile: async(blob: Blob, name: string, _onProgress?: (d: {state?: string, progress?: number})=>void)=>{ | |||||
| downloadBlob(blob, name) | |||||
| }, | |||||
| } | |||||
| actions = {...this.defaultActions} | |||||
| } |
| import {onChange, serialize} from 'ts-browser-helpers' | |||||
| import { | |||||
| BasicDepthPacking, | |||||
| Color, | |||||
| Euler, | |||||
| LinearFilter, | |||||
| MeshDepthMaterial, | |||||
| NoBlending, | |||||
| NoColorSpace, | |||||
| OrthographicCamera, | |||||
| RGBAFormat, | |||||
| UnsignedByteType, | |||||
| Vector3, | |||||
| WebGLRenderTarget, | |||||
| } from 'three' | |||||
| import {BaseGroundPlugin} from '../base/BaseGroundPlugin' | |||||
| import {GBufferRenderPass} from '../../postprocessing' | |||||
| import {ThreeViewer} from '../../viewer' | |||||
| import {IRenderTarget} from '../../rendering' | |||||
| import {uiFolderContainer, uiSlider, uiToggle} from 'uiconfig.js' | |||||
| import {HVBlurHelper} from '../../three/utils/HVBlurHelper' | |||||
| import {shaderReplaceString} from '../../utils' | |||||
| @uiFolderContainer('Contact Shadow Ground') | |||||
| export class ContactShadowGroundPlugin extends BaseGroundPlugin { | |||||
| static readonly PluginType = 'ContactShadowGroundPlugin' | |||||
| @uiToggle('Contact Shadows') | |||||
| @onChange(ContactShadowGroundPlugin.prototype.refresh) | |||||
| @serialize() contactShadows = true | |||||
| @uiSlider('Shadow Scale', [0, 2]) | |||||
| @serialize() | |||||
| @onChange(ContactShadowGroundPlugin.prototype._refreshShadowCameraFrustum) | |||||
| shadowScale = 1 | |||||
| @uiSlider('Shadow Height', [0, 20]) | |||||
| @serialize() | |||||
| @onChange(ContactShadowGroundPlugin.prototype._refreshShadowCameraFrustum) | |||||
| shadowHeight = 5 | |||||
| @uiSlider('Blur Amount', [0, 10]) | |||||
| @serialize() | |||||
| @onChange(ContactShadowGroundPlugin.prototype._setDirty) | |||||
| blurAmount = 1 | |||||
| shadowCamera = new OrthographicCamera(-1, 1, 1, -1, 0.001, this.shadowHeight) | |||||
| private _depthPass?: GBufferRenderPass<'contactShadowGround', WebGLRenderTarget> | |||||
| private _blurHelper?: HVBlurHelper | |||||
| constructor() { | |||||
| super() | |||||
| this._refreshShadowCameraFrustum = this._refreshShadowCameraFrustum.bind(this) | |||||
| this.refresh = this.refresh.bind(this) | |||||
| } | |||||
| onAdded(viewer: ThreeViewer): void { | |||||
| const target = viewer.renderManager.createTarget<IRenderTarget & WebGLRenderTarget>({ | |||||
| type: UnsignedByteType, | |||||
| format: RGBAFormat, | |||||
| colorSpace: NoColorSpace, | |||||
| size: {width: 512, height: 512}, | |||||
| generateMipmaps: false, | |||||
| depthBuffer: true, | |||||
| minFilter: LinearFilter, | |||||
| magFilter: LinearFilter, | |||||
| // isAntialiased: this._viewer.isAntialiased, | |||||
| }) | |||||
| target.texture.name = 'groundContactDepthTexture' | |||||
| // https://github.com/mrdoob/three.js/blob/master/examples/webgl_shadow_contact.html | |||||
| const material = new MeshDepthMaterial({ | |||||
| // depthPacking: RGBADepthPacking, // todo | |||||
| depthPacking: BasicDepthPacking, | |||||
| transparent: false, | |||||
| blending: NoBlending, | |||||
| }) | |||||
| material.onBeforeCompile = (shader) => { | |||||
| shader.uniforms.opacity.value = 1. | |||||
| shader.fragmentShader = shaderReplaceString(shader.fragmentShader, | |||||
| 'gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );', | |||||
| 'gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), 1.0 );', | |||||
| // 'gl_FragColor = vec4( vec3( 0.0 ), ( 1.0 - fragCoordZ ) * darkness );', | |||||
| ) | |||||
| } | |||||
| this._depthPass = new GBufferRenderPass('contactShadowGround', target, material, new Color(0, 0, 0), 0) | |||||
| this._blurHelper = new HVBlurHelper(viewer) | |||||
| super.onAdded(viewer) | |||||
| } | |||||
| onRemove(viewer: ThreeViewer): void { | |||||
| const target = this._depthPass?.target | |||||
| if (target) this._viewer?.renderManager.disposeTarget(target) | |||||
| this._depthPass?.dispose() | |||||
| this._depthPass = undefined | |||||
| this._blurHelper?.dispose() | |||||
| this._blurHelper = undefined | |||||
| return super.onRemove(viewer) | |||||
| } | |||||
| // todo: dispose target, material, pass and stuff | |||||
| protected _postFrame() { | |||||
| super._postFrame() | |||||
| if (!this._viewer) return | |||||
| } | |||||
| protected _preRender() { | |||||
| super._preRender() | |||||
| if (!this._viewer || !this._depthPass || !this._blurHelper) return | |||||
| this._depthPass.scene = this._viewer.scene | |||||
| this._depthPass.camera = this.shadowCamera | |||||
| this._depthPass.render(this._viewer.renderManager.renderer, null) | |||||
| const blurTarget = this._viewer.renderManager.getTempTarget<IRenderTarget&WebGLRenderTarget>({ | |||||
| type: UnsignedByteType, | |||||
| format: RGBAFormat, | |||||
| colorSpace: NoColorSpace, | |||||
| size: {width: 1024, height: 1024}, | |||||
| generateMipmaps: false, | |||||
| depthBuffer: false, | |||||
| minFilter: LinearFilter, | |||||
| magFilter: LinearFilter, | |||||
| // isAntialiased: this._viewer.isAntialiased, | |||||
| }) | |||||
| this._blurHelper.blur(this._depthPass.target.texture, this._depthPass.target, blurTarget, this.blurAmount / 256) | |||||
| this._blurHelper.blur(this._depthPass.target.texture, this._depthPass.target, blurTarget, 0.4 * this.blurAmount / 256) | |||||
| this._viewer.renderManager.releaseTempTarget(blurTarget) | |||||
| } | |||||
| protected _refreshTransform() { | |||||
| super._refreshTransform() | |||||
| if (!this._mesh) return | |||||
| if (!this._viewer) return | |||||
| this.shadowCamera.position.copy(this._mesh.getWorldPosition(new Vector3())) | |||||
| this.shadowCamera.setRotationFromEuler(new Euler(Math.PI / 2., 0, 0)) | |||||
| this.shadowCamera.updateMatrixWorld() | |||||
| this._refreshShadowCameraFrustum() | |||||
| this._mesh.scale.y = -this.size | |||||
| } | |||||
| private _refreshShadowCameraFrustum() { | |||||
| if (!this.shadowCamera) return | |||||
| this.shadowCamera.left = -this.size / (2 * this.shadowScale) | |||||
| this.shadowCamera.right = this.size / (2 * this.shadowScale) | |||||
| this.shadowCamera.top = this.size / (2 * this.shadowScale) | |||||
| this.shadowCamera.bottom = -this.size / (2 * this.shadowScale) | |||||
| this.shadowCamera.far = this.shadowHeight | |||||
| this.shadowCamera.updateProjectionMatrix() | |||||
| this._setDirty() | |||||
| } | |||||
| private _setDirty() { | |||||
| this._viewer?.setDirty() | |||||
| } | |||||
| protected _removeMaterial() { | |||||
| if (!this._material) return | |||||
| // todo: remove map or render target thats assigned | |||||
| super._removeMaterial() | |||||
| } | |||||
| public refresh(): void { | |||||
| if (!this._viewer) return | |||||
| // todo: shadow enabled check | |||||
| super.refresh() | |||||
| } | |||||
| protected _refreshMaterial(): boolean { | |||||
| if (!this._viewer) return false | |||||
| const isNewMaterial = super._refreshMaterial() | |||||
| if (!this._material) return isNewMaterial | |||||
| this._material.alphaMap = this._depthPass?.target.texture || null | |||||
| if (isNewMaterial) { | |||||
| this._material.roughness = 1 | |||||
| this._material.metalness = 0 | |||||
| this._material.color.set(0x111111) | |||||
| this._material.transparent = true | |||||
| this._material.userData.ssreflDisabled = true // todo: unset this in remove material. | |||||
| this._material.userData.ssreflNonPhysical = false | |||||
| // this._material.materialObject.userData.inverseAlphaMap = false // this must be false, if getting inverted colors, check clear color of gbuffer render pass. | |||||
| } | |||||
| return isNewMaterial | |||||
| } | |||||
| } |
| // base | // base | ||||
| export {PipelinePassPlugin} from './base/PipelinePassPlugin' | export {PipelinePassPlugin} from './base/PipelinePassPlugin' | ||||
| export {BaseImporterPlugin} from './base/BaseImporterPlugin' | export {BaseImporterPlugin} from './base/BaseImporterPlugin' | ||||
| export {BaseGroundPlugin} from './base/BaseGroundPlugin' | |||||
| // pipeline | // pipeline | ||||
| export {ProgressivePlugin} from './pipeline/ProgressivePlugin' | export {ProgressivePlugin} from './pipeline/ProgressivePlugin' | ||||
| export {KTXLoadPlugin} from './import/KTXLoadPlugin' | export {KTXLoadPlugin} from './import/KTXLoadPlugin' | ||||
| export {KTX2LoadPlugin} from './import/KTX2LoadPlugin' | export {KTX2LoadPlugin} from './import/KTX2LoadPlugin' | ||||
| // export | |||||
| export {CanvasSnapshotPlugin, CanvasSnipperPlugin} from './export/CanvasSnapshotPlugin' | |||||
| export {FileTransferPlugin} from './export/FileTransferPlugin' | |||||
| // postprocessing | // postprocessing | ||||
| export {AScreenPassExtensionPlugin} from './postprocessing/AScreenPassExtensionPlugin' | export {AScreenPassExtensionPlugin} from './postprocessing/AScreenPassExtensionPlugin' | ||||
| export {TonemapPlugin} from './postprocessing/TonemapPlugin' | export {TonemapPlugin} from './postprocessing/TonemapPlugin' | ||||
| export {HDRiGroundPlugin} from './extras/HDRiGroundPlugin' | export {HDRiGroundPlugin} from './extras/HDRiGroundPlugin' | ||||
| export {Object3DWidgetsPlugin} from './extras/Object3DWidgetsPlugin' | export {Object3DWidgetsPlugin} from './extras/Object3DWidgetsPlugin' | ||||
| export {Object3DGeneratorPlugin} from './extras/Object3DGeneratorPlugin' | export {Object3DGeneratorPlugin} from './extras/Object3DGeneratorPlugin' | ||||
| export {ContactShadowGroundPlugin} from './extras/ContactShadowGroundPlugin' |
| import {ShaderMaterial, Texture, WebGLRenderTarget} from 'three' | |||||
| import {HorizontalBlurShader} from 'three/examples/jsm/shaders/HorizontalBlurShader' | |||||
| import {VerticalBlurShader} from 'three/examples/jsm/shaders/VerticalBlurShader' | |||||
| import {ThreeViewer} from '../../viewer' | |||||
| import {IRenderTarget} from '../../rendering' | |||||
| export class HVBlurHelper { | |||||
| horizontalBlurMaterial = new ShaderMaterial(HorizontalBlurShader) | |||||
| verticalBlurMaterial = new ShaderMaterial(VerticalBlurShader) | |||||
| constructor(private _viewer: ThreeViewer) { | |||||
| this.horizontalBlurMaterial.depthTest = false | |||||
| this.verticalBlurMaterial.depthTest = false | |||||
| } | |||||
| blur(source: Texture, dest: IRenderTarget & WebGLRenderTarget, tempTarget: IRenderTarget & WebGLRenderTarget, amountMultiplier = 1) { | |||||
| this.horizontalBlurMaterial.uniforms.h.value = amountMultiplier | |||||
| this.verticalBlurMaterial.uniforms.v.value = amountMultiplier | |||||
| this._viewer.renderManager.blit(tempTarget, { | |||||
| material: this.horizontalBlurMaterial, | |||||
| clear: true, | |||||
| source: source, // this._depthPass.target.texture, | |||||
| }) | |||||
| // this._viewer.renderManager.blit(this._depthPass.target, { | |||||
| this._viewer.renderManager.blit(dest, { | |||||
| material: this.verticalBlurMaterial, | |||||
| clear: true, | |||||
| source: tempTarget.texture, | |||||
| }) | |||||
| } | |||||
| dispose() { | |||||
| this.horizontalBlurMaterial.dispose() | |||||
| this.verticalBlurMaterial.dispose() | |||||
| } | |||||
| } |
| export {threeConstMappings} from './const-mappings' | export {threeConstMappings} from './const-mappings' | ||||
| export {ObjectPicker} from './ObjectPicker' | export {ObjectPicker} from './ObjectPicker' | ||||
| export {autoGPUInstanceMeshes} from './gpu-instancing' | export {autoGPUInstanceMeshes} from './gpu-instancing' | ||||
| export {HVBlurHelper} from './HVBlurHelper' | |||||
| export {ViewHelper2, type GizmoOrientation, type DomPlacement} from './ViewHelper2' | export {ViewHelper2, type GizmoOrientation, type DomPlacement} from './ViewHelper2' | ||||
| // export {} from './constants' | // export {} from './constants' |
| import {now} from 'ts-browser-helpers' | |||||
| export interface CanvasSnapshotRect { | |||||
| height: number; | |||||
| width: number; | |||||
| x: number; | |||||
| y: number; | |||||
| /** | |||||
| * Use if canvas.width !== canvas.clientWidth or height and rect is based on client rect | |||||
| */ | |||||
| assumeClientRect?: boolean; | |||||
| } | |||||
| export interface CanvasSnapshotOptions { | |||||
| getDataUrl?: boolean, | |||||
| mimeType?: string, | |||||
| quality?: number, // between 0 and 1, only for image/jpeg or image/webp | |||||
| rect?: CanvasSnapshotRect, | |||||
| scale?: number, | |||||
| timeout?: number, // in ms, if not specified, will be based on progressive rendering or 200ms | |||||
| displayPixelRatio?: number, | |||||
| } | |||||
| export class CanvasSnapshot { | |||||
| public static Debug = false | |||||
| public static async GetClonedCanvas( | |||||
| canvas: HTMLCanvasElement, | |||||
| { | |||||
| rect = {x: 0, y: 0, width: canvas.width, height: canvas.height, assumeClientRect: false}, | |||||
| displayPixelRatio = 1, | |||||
| scale = 1, | |||||
| }: CanvasSnapshotOptions): Promise<HTMLCanvasElement> { | |||||
| // return canvas.toDataURL(mimeType); | |||||
| // in Safari, images are flipped when premultipliedAlpha is true in canvas, so it works with 2d context, see: https://github.com/pixijs/pixi.js/blob/dev/packages/extract/src/Extract.ts and https://github.com/pixijs/pixi.js/issues/2951 | |||||
| const destCanvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas') as HTMLCanvasElement | |||||
| destCanvas.width = rect.width * scale * displayPixelRatio | |||||
| destCanvas.height = rect.height * scale * displayPixelRatio | |||||
| // const iRect = {...rect} | |||||
| if (rect.assumeClientRect) { | |||||
| rect.x *= canvas.width / (displayPixelRatio * canvas.clientWidth) | |||||
| rect.y *= canvas.height / (displayPixelRatio * canvas.clientHeight) | |||||
| rect.width *= canvas.width / (displayPixelRatio * canvas.clientWidth) | |||||
| rect.height *= canvas.height / (displayPixelRatio * canvas.clientHeight) | |||||
| } | |||||
| const destCtx = destCanvas.getContext('2d') | |||||
| if (!destCtx) { | |||||
| console.error('snapshot: cannot create context') | |||||
| return destCanvas | |||||
| } | |||||
| // console.log(canvas.style.background) | |||||
| const background = canvas.style.background || canvas.parentElement?.style.background || '' | |||||
| if (background.includes('url')) { | |||||
| const url = /url\("(.*)"\)/ig.exec(background)?.[1] | |||||
| if (url) { | |||||
| const img = new Image() | |||||
| img.src = url | |||||
| await new Promise<void>((resolve, reject) => { | |||||
| img.onload = () => resolve() | |||||
| img.onerror = () => reject() | |||||
| if (img.complete) resolve() | |||||
| }) | |||||
| destCtx.drawImage(img, | |||||
| img.width * rect.x * displayPixelRatio / canvas.width, img.height * rect.y * displayPixelRatio / canvas.height, | |||||
| img.width * rect.width * displayPixelRatio / canvas.width, img.height * rect.height * displayPixelRatio / canvas.height, | |||||
| 0, 0, | |||||
| destCanvas.width, | |||||
| destCanvas.height, | |||||
| ) | |||||
| } | |||||
| } else { | |||||
| destCtx.fillStyle = canvas.style.background || canvas.parentElement?.style.backgroundColor || '#00000000' | |||||
| destCtx.fillRect(0, 0, destCanvas.width, destCanvas.height) | |||||
| } | |||||
| destCtx?.drawImage( | |||||
| canvas, | |||||
| rect.x * displayPixelRatio, rect.y * displayPixelRatio, rect.width * displayPixelRatio, rect.height * displayPixelRatio, | |||||
| 0, 0, destCanvas.width, destCanvas.height, | |||||
| ) | |||||
| const debug = this.Debug | |||||
| if (debug) { | |||||
| // console.log( | |||||
| // destCanvas, | |||||
| // ) | |||||
| document.body.appendChild(destCanvas) | |||||
| destCanvas.style.position = 'absolute' | |||||
| destCanvas.style.top = '0' | |||||
| destCanvas.style.left = '0' | |||||
| destCanvas.style.borderWidth = '2px' | |||||
| destCanvas.style.borderColor = '#ff00ff' | |||||
| setTimeout(() => destCanvas.remove(), 5000) | |||||
| } | |||||
| return destCanvas | |||||
| } | |||||
| public static async GetDataUrl(canvas: HTMLCanvasElement, {mimeType = 'image/png', quality, ...options}: CanvasSnapshotOptions): Promise<string> { | |||||
| const clone = await this.GetClonedCanvas(canvas, options) | |||||
| const url = clone.toDataURL(mimeType, quality) | |||||
| if (!this.Debug) clone.remove() | |||||
| return url | |||||
| } | |||||
| // set one of canvas or context to draw in. | |||||
| public static async GetImage(canvas: HTMLCanvasElement, options: CanvasSnapshotOptions = {}): Promise<HTMLImageElement> { | |||||
| const imgUrl = await this.GetDataUrl(canvas, options) | |||||
| return new Promise<HTMLImageElement>((resolve, reject) => { | |||||
| const img = new Image() | |||||
| img.onload = () => resolve(img) | |||||
| img.onerror = () => reject() | |||||
| img.src = imgUrl | |||||
| }) | |||||
| } | |||||
| public static async GetBlob(canvas: HTMLCanvasElement, options: CanvasSnapshotOptions = {}): Promise<Blob> { | |||||
| const clone = await this.GetClonedCanvas(canvas, options) | |||||
| const blob = await new Promise<Blob>((resolve, reject) => { | |||||
| clone.toBlob((b) => { | |||||
| if (b) resolve(b) | |||||
| else reject() | |||||
| }, options.mimeType ?? 'image/png', options.quality) | |||||
| }) | |||||
| if (!this.Debug) clone.remove() | |||||
| return blob | |||||
| } | |||||
| public static async GetFile(canvas: HTMLCanvasElement, filename = 'image.png', options: CanvasSnapshotOptions = {}): Promise<File|string> { | |||||
| return options.getDataUrl ? await this.GetDataUrl(canvas, options) : new File([await this.GetBlob(canvas, options)], filename, { | |||||
| type: options.mimeType ?? 'image/png', | |||||
| lastModified: now(), | |||||
| }) | |||||
| } | |||||
| } |
| Vector2, | Vector2, | ||||
| Vector3, | Vector3, | ||||
| } from 'three' | } from 'three' | ||||
| import {Class, createCanvasElement, onChange, serialize, ValOrArr} from 'ts-browser-helpers' | |||||
| import {Class, createCanvasElement, downloadBlob, onChange, serialize, ValOrArr} from 'ts-browser-helpers' | |||||
| import {TViewerScreenShader} from '../postprocessing' | import {TViewerScreenShader} from '../postprocessing' | ||||
| import { | import { | ||||
| AddObjectOptions, | AddObjectOptions, | ||||
| import {IViewerPlugin, IViewerPluginSync} from './IViewerPlugin' | import {IViewerPlugin, IViewerPluginSync} from './IViewerPlugin' | ||||
| import {uiConfig, UiObjectConfig, uiPanelContainer} from 'uiconfig.js' | import {uiConfig, UiObjectConfig, uiPanelContainer} from 'uiconfig.js' | ||||
| import {IRenderTarget} from '../rendering' | import {IRenderTarget} from '../rendering' | ||||
| import type {CameraViewPlugin, ProgressivePlugin} from '../plugins' | |||||
| import type {CanvasSnapshotPlugin, FileTransferPlugin} from '../plugins' | |||||
| import {CameraViewPlugin, ProgressivePlugin} from '../plugins' | |||||
| // noinspection ES6PreferShortImport | // noinspection ES6PreferShortImport | ||||
| import {DropzonePlugin, DropzonePluginOptions} from '../plugins/interaction/DropzonePlugin' | import {DropzonePlugin, DropzonePluginOptions} from '../plugins/interaction/DropzonePlugin' | ||||
| // noinspection ES6PreferShortImport | // noinspection ES6PreferShortImport | ||||
| * Render scale, 1 = full resolution, 0.5 = half resolution, 2 = double resolution. | * Render scale, 1 = full resolution, 0.5 = half resolution, 2 = double resolution. | ||||
| * Same as pixelRatio in three.js | * Same as pixelRatio in three.js | ||||
| * Can be set to `window.devicePixelRatio` to render at device resolution in browsers. | * Can be set to `window.devicePixelRatio` to render at device resolution in browsers. | ||||
| * An optimal value is `Math.min(2, window.devicePixelRatio)` to prevent issues on mobile. | |||||
| * An optimal value is `Math.min(2, window.devicePixelRatio)` to prevent issues on mobile. This is set when 'auto' is passed. | |||||
| */ | */ | ||||
| renderScale?: number | |||||
| renderScale?: number | 'auto' | |||||
| debug?: boolean | debug?: boolean | ||||
| zPrepass: options.zPrepass ?? options.useGBufferDepth ?? false, | zPrepass: options.zPrepass ?? options.useGBufferDepth ?? false, | ||||
| depthBuffer: !(options.zPrepass ?? options.useGBufferDepth ?? false), | depthBuffer: !(options.zPrepass ?? options.useGBufferDepth ?? false), | ||||
| screenShader: options.screenShader, | screenShader: options.screenShader, | ||||
| renderScale: options.renderScale, | |||||
| renderScale: typeof options.renderScale === 'string' ? options.renderScale === 'auto' ? | |||||
| Math.min(2, window.devicePixelRatio) : parseFloat(options.renderScale) : | |||||
| options.renderScale, | |||||
| }) | }) | ||||
| this.renderManager.addEventListener('animationLoop', this._animationLoop as any) | this.renderManager.addEventListener('animationLoop', this._animationLoop as any) | ||||
| this.renderManager.addEventListener('resize', ()=> this._scene.mainCamera.refreshAspect()) | this.renderManager.addEventListener('resize', ()=> this._scene.mainCamera.refreshAspect()) | ||||
| } | } | ||||
| async getScreenshotBlob({mimeType = 'image/jpeg', quality = 90} = {}): Promise<Blob | null | undefined> { | async getScreenshotBlob({mimeType = 'image/jpeg', quality = 90} = {}): Promise<Blob | null | undefined> { | ||||
| const plugin = this.getPlugin<CanvasSnapshotPlugin>('CanvasSnapshotPlugin') | |||||
| if (plugin) { | |||||
| return plugin.getFile('snapshot.' + mimeType.split('/')[1], {mimeType, quality, waitForProgressive: true}) | |||||
| } | |||||
| const blobPromise = async()=> new Promise<Blob|null>((resolve) => { | const blobPromise = async()=> new Promise<Blob|null>((resolve) => { | ||||
| this._canvas.toBlob((blob) => { | this._canvas.toBlob((blob) => { | ||||
| resolve(blob) | resolve(blob) | ||||
| }) | }) | ||||
| } | } | ||||
| async getScreenshotDataUrl({mimeType = 'image/jpeg', quality = 90} = {}): Promise<string | null | undefined> { | |||||
| async getScreenshotDataUrl({mimeType = 'image/jpeg', quality = 0.9} = {}): Promise<string | null | undefined> { | |||||
| if (!this.renderEnabled) return this._canvas.toDataURL(mimeType, quality) | if (!this.renderEnabled) return this._canvas.toDataURL(mimeType, quality) | ||||
| return await this.doOnce('postFrame', () => this._canvas.toDataURL(mimeType, quality)) | return await this.doOnce('postFrame', () => this._canvas.toDataURL(mimeType, quality)) | ||||
| } | } | ||||
| super.dispatchEvent({...event, type: '*', eType: event.type}) | super.dispatchEvent({...event, type: '*', eType: event.type}) | ||||
| } | } | ||||
| /** | |||||
| * Uses the {@link FileTransferPlugin} to export a blob. If the plugin is not available, it will download the blob. | |||||
| * FileTransferPlugin can be configured by other plugins to export the blob to a specific location like local file system, cloud storage, etc. | |||||
| * @param blob - The blob or file to export/download | |||||
| * @param name | |||||
| */ | |||||
| async exportBlob(blob: Blob|File, name?: string) { | |||||
| const tr = this.getPlugin<FileTransferPlugin>('FileTransferPlugin') | |||||
| name = name ?? (blob as File).name ?? 'file' | |||||
| if (!tr) { | |||||
| downloadBlob(blob, name) | |||||
| return | |||||
| } | |||||
| await tr.exportFile(blob, name) | |||||
| } | |||||
| private _setActiveCameraView(event: any = {}): void { | private _setActiveCameraView(event: any = {}): void { | ||||
| if (event.type === 'setView') { | if (event.type === 'setView') { | ||||
| if (!event.camera) { | if (!event.camera) { |