threepipe
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

PerspectiveCamera2.ts 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. import {Camera, Event, IUniform, Object3D, PerspectiveCamera, Vector3} from 'three'
  2. import {generateUiConfig, uiInput, UiObjectConfig, uiSlider, uiToggle, uiVector} from 'uiconfig.js'
  3. import {onChange, onChange2, onChange3, serialize} from 'ts-browser-helpers'
  4. import type {ICamera, ICameraEvent, ICameraUserData, TCameraControlsMode} from '../ICamera'
  5. import {ICameraSetDirtyOptions} from '../ICamera'
  6. import type {ICameraControls, TControlsCtor} from './ICameraControls'
  7. import {OrbitControls3} from '../../three'
  8. import {IObject3D} from '../IObject'
  9. import {ThreeSerialization} from '../../utils'
  10. import {iCameraCommons} from '../object/iCameraCommons'
  11. import {bindToValue} from '../../three/utils/decorators'
  12. import {makeICameraCommonUiConfig} from '../object/IObjectUi'
  13. // todo: maybe change domElement to some wrapper/base class of viewer
  14. export class PerspectiveCamera2 extends PerspectiveCamera implements ICamera {
  15. assetType = 'camera' as const
  16. get controls(): ICameraControls | undefined {
  17. return this._controls
  18. }
  19. @uiInput('Name') name: string
  20. @serialize('camControls')
  21. private _controls?: ICameraControls
  22. private _currentControlsMode: TCameraControlsMode = ''
  23. @onChange2(PerspectiveCamera2.prototype.refreshCameraControls)
  24. controlsMode: TCameraControlsMode
  25. /**
  26. * It should be the canvas actually
  27. * @private
  28. */
  29. private _canvas?: HTMLCanvasElement
  30. get isMainCamera(): boolean {
  31. return this.userData.__isMainCamera || false
  32. }
  33. @serialize()
  34. userData: ICameraUserData = {}
  35. @onChange3(PerspectiveCamera2.prototype.setDirty)
  36. @uiSlider('Field Of View', [1, 180], 0.001)
  37. @serialize() fov: number
  38. @onChange3(PerspectiveCamera2.prototype.setDirty)
  39. @serialize() focus: number
  40. @onChange3(PerspectiveCamera2.prototype.setDirty)
  41. // @uiSlider('Zoom', [0.001, 20], 0.001)
  42. @serialize() zoom: number
  43. @uiVector('Position')
  44. @serialize() readonly position: Vector3
  45. @onChange3(PerspectiveCamera2.prototype.setDirty)
  46. @uiVector('Target')
  47. @serialize() readonly target: Vector3 = new Vector3(0, 0, 0)
  48. /**
  49. * Automatically manage aspect ratio based on window/canvas size.
  50. * Defaults to `true` if {@link domElement}(canvas) is set.
  51. */
  52. @serialize()
  53. @onChange2(PerspectiveCamera2.prototype.refreshAspect)
  54. @uiToggle('Auto Aspect')
  55. autoAspect: boolean
  56. /**
  57. * Near clipping plane.
  58. * This is managed by RootScene for active cameras
  59. * To change the minimum that's possible set {@link minNearPlane}
  60. * To use a fixed value set {@link autoNearFar} to false and set {@link minNearPlane}
  61. */
  62. @onChange2(PerspectiveCamera2.prototype._nearFarChanged)
  63. near = 0.01
  64. /**
  65. * Far clipping plane.
  66. * This is managed by RootScene for active cameras
  67. * To change the maximum that's possible set {@link maxFarPlane}
  68. * To use a fixed value set {@link autoNearFar} to false and set {@link maxFarPlane}
  69. */
  70. @onChange2(PerspectiveCamera2.prototype._nearFarChanged)
  71. far = 50
  72. /**
  73. * Automatically manage near and far clipping planes based on scene size.
  74. */
  75. @bindToValue({obj: 'userData', onChange: 'setDirty'})
  76. autoNearFar = true
  77. /**
  78. * Minimum near clipping plane allowed. (Distance from camera)
  79. * @default 0.2
  80. */
  81. @bindToValue({obj: 'userData', onChange: 'setDirty'})
  82. minNearPlane = 0.2
  83. /**
  84. * Maximum far clipping plane allowed. (Distance from camera)
  85. */
  86. @bindToValue({obj: 'userData', onChange: 'setDirty'})
  87. maxFarPlane = 1000
  88. constructor(controlsMode?: TCameraControlsMode, domElement?: HTMLCanvasElement, autoAspect?: boolean, fov?: number, aspect?: number) {
  89. super(fov, aspect)
  90. this._canvas = domElement
  91. this.autoAspect = autoAspect || !!domElement
  92. iCameraCommons.upgradeCamera.call(this) // todo: test if autoUpgrade = false works as expected if we call upgradeObject3D externally after constructor, because we have setDirty, refreshTarget below.
  93. this.controlsMode = controlsMode || ''
  94. this.refreshTarget(undefined, false)
  95. // if (!camera)
  96. // this.targetUpdated(false)
  97. this.setDirty()
  98. // if (domElement)
  99. // domElement.style.touchAction = 'none' // this is done in orbit controls anyway
  100. // const ae = this._canvas.addEventListener
  101. // todo: this breaks UI.
  102. // this._canvas.addEventListener = (type: string, listener: any, options1: any) => { // see https://github.com/mrdoob/three.js/pull/19782
  103. // ae(type, listener, type === 'wheel' && typeof options1 !== 'boolean' ? {
  104. // ...typeof options1 === 'object' ? options1 : {},
  105. // capture: false,
  106. // passive: false,
  107. // } : options1)
  108. // }
  109. // this.refreshCameraControls() // this is done on set controlsMode
  110. // const target = this.target
  111. }
  112. // @serialize('camOptions') //todo handle deserialization of this
  113. // region interactionsEnabled
  114. private _interactionsEnabled = true
  115. get canUserInteract() {
  116. return this._interactionsEnabled && this.isMainCamera && this.controlsMode !== ''
  117. }
  118. get interactionsEnabled(): boolean {
  119. return this._interactionsEnabled
  120. }
  121. set interactionsEnabled(value: boolean) {
  122. if (this._interactionsEnabled !== value) {
  123. this._interactionsEnabled = value
  124. this.refreshCameraControls(true)
  125. }
  126. }
  127. // endregion
  128. // region refreshing
  129. setDirty(options?: ICameraSetDirtyOptions|Event): void {
  130. if (!this._positionWorld) return // class not initialized
  131. if (options?.key === 'fov') this.updateProjectionMatrix()
  132. this.getWorldPosition(this._positionWorld)
  133. iCameraCommons.setDirty.call(this, options)
  134. this._camUi.forEach(u=>u?.uiRefresh?.(false, 'postFrame', 1)) // because camera changes a lot. so we dont want to deep refresh ui on every change
  135. }
  136. /**
  137. * when aspect ratio is set to auto it must be refreshed on resize, this is done by the viewer for the main camera.
  138. * @param setDirty
  139. */
  140. refreshAspect(setDirty = true): void {
  141. if (this.autoAspect) {
  142. if (!this._canvas) console.error('cannot calculate aspect ratio without canvas/container')
  143. else {
  144. this.aspect = this._canvas.clientWidth / this._canvas.clientHeight
  145. this.updateProjectionMatrix?.()
  146. }
  147. }
  148. if (setDirty) this.setDirty()
  149. // console.log('refreshAspect', this._options.aspect)
  150. }
  151. protected _nearFarChanged() {
  152. if (this.view === undefined) return // not initialized yet
  153. this.updateProjectionMatrix?.()
  154. }
  155. refreshUi = iCameraCommons.refreshUi
  156. refreshTarget = iCameraCommons.refreshTarget
  157. activateMain = iCameraCommons.activateMain
  158. deactivateMain = iCameraCommons.deactivateMain
  159. // endregion
  160. // region controls
  161. // todo: move orbit to a plugin maybe? so that its not forced
  162. private _controlsCtors = new Map<string, TControlsCtor>([['orbit', (object, domElement)=>{
  163. const controls = new OrbitControls3(object, domElement ? !domElement.ownerDocument ? domElement.documentElement : domElement : document.body)
  164. // this._controls.enabled = false
  165. // this._controls.listenToKeyEvents(window as any) // optional // todo: this breaks keyboard events in UI like cursor left/right, make option for this
  166. // this._controls.enableKeys = true
  167. controls.screenSpacePanning = true
  168. return controls
  169. }]])
  170. setControlsCtor(key: string, ctor: TControlsCtor, replace = false): void {
  171. if (!replace && this._controlsCtors.has(key)) {
  172. console.error(key + ' already exists.')
  173. return
  174. }
  175. this._controlsCtors.set(key, ctor)
  176. }
  177. removeControlsCtor(key: string): void {
  178. this._controlsCtors.delete(key)
  179. }
  180. private _controlsChanged = ()=>{
  181. if (this._controls && this._controls.target) this.refreshTarget(undefined, false)
  182. this.setDirty({change: 'controls'})
  183. }
  184. private _initCameraControls() {
  185. const mode = this.controlsMode
  186. this._controls = this._controlsCtors.get(mode)?.(this, this._canvas) ?? undefined
  187. if (!this._controls && mode !== '') console.error('Unable to create controls with mode ' + mode + '. Are you missing a plugin?')
  188. this._controls?.addEventListener('change', this._controlsChanged)
  189. this._currentControlsMode = this._controls ? mode : ''
  190. // todo maybe set target like this:
  191. // if (this._controls) this._controls.target = this.target
  192. }
  193. private _disposeCameraControls() {
  194. if (this._controls) {
  195. if (this._controls.target === this.target) this._controls.target = new Vector3() // just in case
  196. this._controls?.removeEventListener('change', this._controlsChanged)
  197. this._controls?.dispose()
  198. }
  199. this._currentControlsMode = ''
  200. this._controls = undefined
  201. }
  202. refreshCameraControls(setDirty = true): void {
  203. if (!this._controlsCtors) return // class not initialized
  204. if (this._controls) {
  205. if (this._currentControlsMode !== this.controlsMode || this !== this._controls.object) { // in-case camera changed or mode changed
  206. this._disposeCameraControls()
  207. this._initCameraControls()
  208. }
  209. } else {
  210. this._initCameraControls()
  211. }
  212. // todo: only for orbit control like controls?
  213. if (this._controls) {
  214. const ce = this.interactionsEnabled
  215. this._controls.enabled = ce
  216. if (ce) this.up.copy(Object3D.DEFAULT_UP)
  217. }
  218. if (setDirty) this.setDirty()
  219. this.refreshUi()
  220. }
  221. // endregion
  222. // region serialization
  223. /**
  224. * Serializes this camera with controls to JSON.
  225. * @param meta - metadata for serialization
  226. * @param baseOnly - Calls only super.toJSON, does internal three.js serialization. Set it to true only if you know what you are doing.
  227. */
  228. toJSON(meta?: any, baseOnly = false): any {
  229. if (baseOnly) return super.toJSON(meta)
  230. // todo add camOptions for backwards compatibility?
  231. return ThreeSerialization.Serialize(this, meta, true)
  232. }
  233. fromJSON(data: any, meta?: any): this | null {
  234. if (data.camOptions || data.aspect === 'auto')
  235. data = {...data}
  236. if (data.camOptions) {
  237. const op = data.camOptions
  238. if (op.fov) data.fov = op.fov
  239. if (op.focus) data.focus = op.focus
  240. if (op.zoom) data.zoom = op.zoom
  241. if (op.aspect) data.aspect = op.aspect
  242. if (op.controlsMode) data.controlsMode = op.controlsMode
  243. // todo: add support for this
  244. // if (op.left) data.left = op.left
  245. // if (op.right) data.right = op.right
  246. // if (op.top) data.top = op.top
  247. // if (op.bottom) data.bottom = op.bottom
  248. // if (op.frustumSize) data.frustumSize = op.frustumSize
  249. // if (op.controlsEnabled) data.controlsEnabled = op.controlsEnabled
  250. delete data.camOptions
  251. }
  252. if (data.aspect === 'auto') {
  253. data.aspect = this.aspect
  254. this.autoAspect = true
  255. }
  256. // if (data.cameraObject) this._camera.fromJSON(data.cameraObject)
  257. // todo: add check for OrbitControls being not deserialized(inited properly) if it doesn't exist yet (if it is not inited properly)
  258. // console.log(JSON.parse(JSON.stringify(data)))
  259. ThreeSerialization.Deserialize(data, this, meta, true)
  260. this.setDirty({change: 'deserialize'})
  261. return this
  262. }
  263. // endregion
  264. // region utils/others
  265. // for shader prop updater
  266. private _positionWorld = new Vector3()
  267. updateShaderProperties(material: {defines: Record<string, string | number | undefined>; uniforms: {[p: string]: IUniform}}): this {
  268. material.uniforms.cameraPositionWorld?.value?.copy(this._positionWorld)
  269. material.uniforms.cameraNearFar?.value?.set(this.near, this.far)
  270. if (material.uniforms.projection) material.uniforms.projection.value = this.projectionMatrix // todo: rename to projectionMatrix2?
  271. material.defines.PERSPECTIVE_CAMERA = this.type === 'PerspectiveCamera' ? '1' : '0'
  272. // material.defines.ORTHOGRAPHIC_CAMERA = this.type === 'OrthographicCamera' ? '1' : '0' // todo
  273. return this
  274. }
  275. dispose(): void {
  276. this._disposeCameraControls()
  277. // todo: anything else?
  278. // iObjectCommons.dispose and dispatch event dispose is called automatically because of updateObject3d
  279. }
  280. // endregion
  281. // region ui
  282. private _camUi: UiObjectConfig[] = [
  283. ...generateUiConfig(this),
  284. {
  285. type: 'input',
  286. label: ()=>(this.autoNearFar ? 'Min' : '') + ' Near',
  287. property: [this, 'minNearPlane'],
  288. },
  289. {
  290. type: 'input',
  291. label: ()=>(this.autoNearFar ? 'Max' : '') + ' Far',
  292. property: [this, 'maxFarPlane'],
  293. },
  294. {
  295. type: 'input',
  296. label: 'Auto Near Far',
  297. property: [this, 'autoNearFar'],
  298. },
  299. ()=>({ // because _controlsCtors can change
  300. type: 'dropdown',
  301. label: 'Controls Mode',
  302. property: [this, 'controlsMode'],
  303. children: ['', 'orbit', ...this._controlsCtors.keys()].map(v=>({label: v === '' ? 'none' : v, value:v})),
  304. onChange: () => this.refreshCameraControls(),
  305. }),
  306. ()=>makeICameraCommonUiConfig.call(this, this.uiConfig),
  307. ]
  308. uiConfig: UiObjectConfig = {
  309. type: 'folder',
  310. label: ()=>this.name || 'Camera',
  311. children: [
  312. ...this._camUi,
  313. // todo hack for zoom in and out for now.
  314. ()=>(this._controls as OrbitControls3)?.zoomIn ? {
  315. type: 'button',
  316. label: 'Zoom in',
  317. value: ()=> (this._controls as OrbitControls3)?.zoomIn(1),
  318. } : {},
  319. ()=>(this._controls as OrbitControls3)?.zoomOut ? {
  320. type: 'button',
  321. label: 'Zoom out',
  322. value: ()=> (this._controls as OrbitControls3)?.zoomOut(1),
  323. } : {},
  324. ()=>this._controls?.uiConfig,
  325. ],
  326. }
  327. // endregion
  328. // region deprecated/old
  329. @onChange((k: string, v: boolean)=>{
  330. if (!v) console.warn('Setting camera invisible is not supported', k, v)
  331. })
  332. visible: boolean
  333. get isActiveCamera(): boolean {
  334. return this.isMainCamera
  335. }
  336. /**
  337. * @deprecated use `<T>camera.controls` instead
  338. */
  339. getControls<T extends ICameraControls>(): T|undefined {
  340. return this._controls as any as T
  341. }
  342. /**
  343. * @deprecated use `this` instead
  344. */
  345. get cameraObject(): this {
  346. return this
  347. }
  348. /**
  349. * @deprecated use `this` instead
  350. */
  351. get modelObject(): this {
  352. return this
  353. }
  354. /**
  355. * @deprecated
  356. * @param setDirty
  357. */
  358. targetUpdated(setDirty = true): void {
  359. if (setDirty) this.setDirty()
  360. }
  361. // setCameraOptions<T extends Partial<IPerspectiveCameraOptions | IOrthographicCameraOptions>>(value: T, setDirty = true): void {
  362. // const ops: any = {...value}
  363. //
  364. // this._refreshCameraOptions(false)
  365. // this.refreshCameraControls(false)
  366. // if (setDirty) this.setDirty()
  367. // }
  368. // not to be used
  369. // private _changeType(setDirty = true) {
  370. // // let cam = this._camera.modelObject
  371. //
  372. // // change of type, not supported now.
  373. // // if (this._options.type !== cam.type) {
  374. // // const cam2 = this._options.type === 'PerspectiveCamera' ? new PerspectiveCamera() : new OrthographicCamera()
  375. // // cam2.name = this._camera.name
  376. // // cam2.near = this._camera.modelObject.near
  377. // // cam2.far = this._camera.modelObject.far
  378. // // cam2.zoom = this._camera.modelObject.zoom
  379. // // cam2.scale.copy(this._camera.modelObject.scale)
  380. // //
  381. // // const isActive = this._isMainCamera
  382. // // if (isActive) this.deactivateMain()
  383. // // this._camera = this._setCameraObject(cam2)
  384. // // cam = this._camera.modelObject
  385. // // if (isActive) this.activateMain()
  386. // // this._camera.modelObject.updateProjectionMatrix()
  387. // // }
  388. //
  389. // // this._nearFarChanged() // this updates projection matrix todo: move to setDirty
  390. //
  391. // if (setDirty) this.setDirty()
  392. // }
  393. // private _cameraObjectUpdate = (e: any)=>{
  394. // this.setDirty(e)
  395. // }
  396. // private _setCameraObject(cam: OrthographicCamera | PerspectiveCamera) {
  397. // if (this._camera) this._camera.removeEventListener('objectUpdate', this._cameraObjectUpdate)
  398. // this._camera = setupIModel(cam as any)
  399. // this._camera.addEventListener('objectUpdate', this._cameraObjectUpdate)
  400. // return this._camera
  401. // }
  402. // for ortho
  403. // private _frustumSize: number | undefined = undefined
  404. //
  405. // get frustumSize(): number | undefined {
  406. // return this._frustumSize
  407. // }
  408. //
  409. // set frustumSize(value: number | undefined) {
  410. // this._frustumSize = value
  411. // if (value !== undefined) {
  412. // cam.top = value / 2
  413. // cam.bottom = -value / 2
  414. // cam.left = aspect * value / 2
  415. // cam.right = -aspect * value / 2
  416. // }
  417. // this.setDirty()
  418. // }
  419. // endregion
  420. // region inherited type fixes
  421. // re-declaring from IObject3D because: https://github.com/microsoft/TypeScript/issues/16936
  422. traverse: (callback: (object: IObject3D) => void) => void
  423. traverseVisible: (callback: (object: IObject3D) => void) => void
  424. traverseAncestors: (callback: (object: IObject3D) => void) => void
  425. getObjectById: <T extends IObject3D = IObject3D>(id: number) => T | undefined
  426. getObjectByName: <T extends IObject3D = IObject3D>(name: string) => T | undefined
  427. getObjectByProperty: <T extends IObject3D = IObject3D>(name: string, value: string) => T | undefined
  428. copy: (source: ICamera|Camera, recursive?: boolean, distanceFromTarget?: number) => this
  429. clone: (recursive?: boolean) => this
  430. add: (...object: IObject3D[]) => this
  431. remove: (...object: IObject3D[]) => this
  432. dispatchEvent: (event: ICameraEvent) => void
  433. parent: IObject3D | null
  434. children: IObject3D[]
  435. // endregion
  436. }