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

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