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 21KB

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