video2DProjectionComponent.vue 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. <script setup lang="ts">
  2. // 视频 2D 投射组件
  3. // 参考 Mars3D Video2D: http://mars3d.cn/editor-vue.html?key=ex_6_12_4&id=graphic/video/video2D
  4. // 仅投射示例视频: https://opensource.icegl.cn/plugins/visualArts/video/vlg.mp4
  5. import * as THREE from 'three'
  6. import { onBeforeUnmount, shallowRef, watch } from 'vue'
  7. const props = withDefaults(
  8. defineProps<{
  9. angle?: number
  10. angle2?: number
  11. distance?: number
  12. flipX?: boolean
  13. frustumColor?: string
  14. heading?: number
  15. loop?: boolean
  16. opacity?: number
  17. pitch?: number
  18. showFrustum?: boolean
  19. stRotationDegree?: number
  20. videoTint?: string
  21. }>(),
  22. {
  23. angle: 46,
  24. angle2: 25,
  25. distance: 6,
  26. flipX: false,
  27. frustumColor: '#00ffff',
  28. heading: 0,
  29. loop: true,
  30. opacity: 0.95,
  31. pitch: -20,
  32. showFrustum: true,
  33. stRotationDegree: 0,
  34. videoTint: '#ffffff',
  35. },
  36. )
  37. const VIDEO_URL = 'https://dcser.icegl.cn/video/vlg.mp4'
  38. const clamp = (v: number | undefined, min: number, max: number, fallback: number) =>
  39. Number.isFinite(v) ? THREE.MathUtils.clamp(v as number, min, max) : fallback
  40. // —— 视频元素 & 贴图 ——
  41. const videoEl = document.createElement('video')
  42. videoEl.crossOrigin = 'anonymous'
  43. videoEl.loop = props.loop ?? true
  44. videoEl.muted = true
  45. videoEl.autoplay = true
  46. videoEl.playsInline = true
  47. videoEl.setAttribute('playsinline', '')
  48. videoEl.src = VIDEO_URL
  49. const videoTexture = new THREE.VideoTexture(videoEl)
  50. videoTexture.colorSpace = THREE.SRGBColorSpace
  51. videoTexture.minFilter = THREE.LinearFilter
  52. videoTexture.magFilter = THREE.LinearFilter
  53. videoTexture.generateMipmaps = false
  54. videoTexture.wrapS = THREE.RepeatWrapping
  55. videoTexture.wrapT = THREE.RepeatWrapping
  56. videoTexture.center.set(0.5, 0.5)
  57. // 覆写 VideoTexture.update(): 视频处于 ended 状态时跳过 texImage2D 上传,
  58. // 避免 loop 衔接瞬间触发 "texImage2D: no video" WebGL 错误。
  59. // 正常播放时调用原始 update() 保证每帧正常上传。
  60. const _origUpdate = videoTexture.update.bind(videoTexture)
  61. videoTexture.update = function () {
  62. if (videoEl.ended || videoEl.readyState < videoEl.HAVE_CURRENT_DATA) {
  63. return
  64. }
  65. _origUpdate()
  66. }
  67. const videoMaterial = new THREE.MeshBasicMaterial({
  68. color: new THREE.Color(props.videoTint),
  69. depthWrite: false,
  70. map: videoTexture,
  71. opacity: props.opacity,
  72. side: THREE.DoubleSide,
  73. toneMapped: false,
  74. transparent: true,
  75. })
  76. const frustumLineMaterial = new THREE.LineBasicMaterial({
  77. color: new THREE.Color(props.frustumColor),
  78. depthWrite: false,
  79. opacity: 0.9,
  80. toneMapped: false,
  81. transparent: true,
  82. })
  83. // —— Three.js 场景树 ——
  84. const rootGroup = shallowRef(new THREE.Group())
  85. const headingGroup = new THREE.Group()
  86. const pitchGroup = new THREE.Group()
  87. const farPlaneMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), videoMaterial)
  88. farPlaneMesh.renderOrder = 3
  89. const frustumGeo = new THREE.BufferGeometry()
  90. const frustumLines = new THREE.LineSegments(frustumGeo, frustumLineMaterial)
  91. frustumLines.renderOrder = 4
  92. frustumLines.frustumCulled = false
  93. pitchGroup.add(farPlaneMesh, frustumLines)
  94. headingGroup.add(pitchGroup)
  95. rootGroup.value.add(headingGroup)
  96. // —— 视锥几何重建 ——
  97. const rebuildFrustum = () => {
  98. const dist = clamp(props.distance, 0.1, 500, 6)
  99. const halfW = dist * Math.tan(THREE.MathUtils.degToRad(clamp(props.angle, 1, 120, 46)) / 2)
  100. const halfH = dist * Math.tan(THREE.MathUtils.degToRad(clamp(props.angle2, 1, 120, 25)) / 2)
  101. const z = -dist
  102. const pos = new Float32Array([
  103. 0,
  104. 0,
  105. 0,
  106. -halfW,
  107. -halfH,
  108. z,
  109. 0,
  110. 0,
  111. 0,
  112. halfW,
  113. -halfH,
  114. z,
  115. 0,
  116. 0,
  117. 0,
  118. halfW,
  119. halfH,
  120. z,
  121. 0,
  122. 0,
  123. 0,
  124. -halfW,
  125. halfH,
  126. z,
  127. -halfW,
  128. -halfH,
  129. z,
  130. halfW,
  131. -halfH,
  132. z,
  133. halfW,
  134. -halfH,
  135. z,
  136. halfW,
  137. halfH,
  138. z,
  139. halfW,
  140. halfH,
  141. z,
  142. -halfW,
  143. halfH,
  144. z,
  145. -halfW,
  146. halfH,
  147. z,
  148. -halfW,
  149. -halfH,
  150. z,
  151. ])
  152. frustumGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
  153. frustumGeo.computeBoundingSphere()
  154. farPlaneMesh.position.set(0, 0, z)
  155. farPlaneMesh.scale.set(Math.max(halfW * 2, 0.0001), Math.max(halfH * 2, 0.0001), 1)
  156. }
  157. // —— 朝向更新 ——
  158. const updateOrientation = () => {
  159. headingGroup.rotation.y = THREE.MathUtils.degToRad(-clamp(props.heading, -360, 360, 0))
  160. pitchGroup.rotation.x = THREE.MathUtils.degToRad(clamp(props.pitch, -89, 89, -20))
  161. }
  162. // —— 贴图变换 ——
  163. const applyTextureTransform = () => {
  164. videoTexture.repeat.x = props.flipX ? -1 : 1
  165. videoTexture.offset.x = props.flipX ? 1 : 0
  166. videoTexture.rotation = THREE.MathUtils.degToRad(clamp(props.stRotationDegree, 0, 360, 0))
  167. }
  168. // —— 播放控制 ——
  169. const videoPlay = () => {
  170. const p = videoEl.play()
  171. if (p && typeof p.catch === 'function') {
  172. p.catch(() => {})
  173. }
  174. }
  175. const videoPause = () => {
  176. videoEl.pause()
  177. }
  178. const videoStop = () => {
  179. videoEl.pause()
  180. videoEl.currentTime = 0
  181. }
  182. // 初始化
  183. rebuildFrustum()
  184. updateOrientation()
  185. applyTextureTransform()
  186. videoPlay()
  187. watch(
  188. () => [props.angle, props.angle2, props.distance],
  189. () => { rebuildFrustum() },
  190. )
  191. watch(
  192. () => [props.heading, props.pitch],
  193. () => { updateOrientation() },
  194. )
  195. watch(
  196. () => [props.flipX, props.stRotationDegree],
  197. () => { applyTextureTransform() },
  198. )
  199. watch(() => props.loop, (v) => { videoEl.loop = v ?? true })
  200. watch(() => props.opacity, (v) => { videoMaterial.opacity = clamp(v, 0, 1, 0.95) })
  201. watch(() => props.videoTint, (v) => { videoMaterial.color.set(v ?? '#ffffff') })
  202. watch(() => props.frustumColor, (v) => { frustumLineMaterial.color.set(v ?? '#00ffff') })
  203. watch(() => props.showFrustum, (v) => { frustumLines.visible = v ?? true }, { immediate: true })
  204. onBeforeUnmount(() => {
  205. try {
  206. videoEl.pause()
  207. videoEl.removeAttribute('src')
  208. videoEl.load()
  209. }
  210. catch { /* ignore */ }
  211. videoTexture.dispose()
  212. videoMaterial.dispose()
  213. frustumGeo.dispose()
  214. frustumLineMaterial.dispose()
  215. farPlaneMesh.geometry.dispose()
  216. rootGroup.value.removeFromParent()
  217. })
  218. defineExpose({ videoPlay, videoPause, videoStop })
  219. </script>
  220. <template>
  221. <primitive
  222. v-if="rootGroup"
  223. :object="rootGroup"
  224. />
  225. </template>