index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. <template>
  2. <div class="w-full h-full">
  3. <canvas id="canvasDom"></canvas>
  4. <NProgress
  5. class="absolute top-1/2 left-1/2 transform-translate--50%"
  6. v-if="percentageFlag"
  7. type="circle"
  8. processing
  9. :percentage="percentage"
  10. :fill-border-radius="0"
  11. />
  12. <RightClick
  13. v-auth="['mraddPointBtn']"
  14. v-model="rightClickD.show"
  15. :flag="rightClickFlag"
  16. :x="rightClickD.x"
  17. :y="rightClickD.y"
  18. :intersection="rightClickD.intersection"
  19. />
  20. </div>
  21. </template>
  22. <script setup>
  23. import * as THREE from 'three'
  24. import { NProgress } from 'naive-ui'
  25. import { gsap } from 'gsap'
  26. // 导入控制器 https://threejs.org/docs/index.html#examples/zh/controls/OrbitControls
  27. import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
  28. import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
  29. import { RGBELoader } from 'three/addons/loaders/RGBELoader.js'
  30. import { onMounted, onUnmounted, provide, ref } from 'vue'
  31. import { useOutsideHomeStore } from '@/stores/modules/home'
  32. import { useOutsideSystemStore } from '@/stores/modules/system'
  33. import { getAutofitScale, $mitt, hasPermission } from '@/utils'
  34. import storage from '@/utils/storage'
  35. import RightClick from './components/rightClick.vue'
  36. const emits = defineEmits(['onGetData'])
  37. const rightClickD = reactive({
  38. show: false,
  39. x: 0,
  40. y: 0,
  41. intersection: null
  42. })
  43. const rightClickFlag = ref('add')
  44. const { API_MR_CAMERA_GET, API_MR_VIDEO_LIST_GET } = useRequest()
  45. const useHomeStore = useOutsideHomeStore()
  46. const useSystemStore = useOutsideSystemStore()
  47. const percentage = ref(0)
  48. const percentageFlag = ref(true)
  49. const timelineCamera = gsap.timeline()
  50. const timelineControls = gsap.timeline()
  51. // 实现视角平滑移动方法
  52. function translateCamera(cameraPosition, controlsTarget, duration) {
  53. const _duration = duration ? duration : 1
  54. return new Promise((resolve) => {
  55. timelineCamera.to(camera.position, {
  56. x: cameraPosition.x,
  57. y: cameraPosition.y,
  58. z: cameraPosition.z,
  59. duration: _duration,
  60. ease: 'power2.inOut',
  61. onComplete: () => {}
  62. })
  63. timelineControls.to(controls.target, {
  64. x: controlsTarget.x,
  65. y: controlsTarget.y,
  66. z: controlsTarget.z,
  67. duration: _duration,
  68. ease: 'power2.inOut',
  69. onComplete: () => {}
  70. })
  71. setTimeout(() => {
  72. resolve()
  73. }, _duration)
  74. })
  75. }
  76. const sprites = []
  77. function addSprite(arr = []) {
  78. arr.forEach(({ x, y, z, id, RtspMain }) => {
  79. const sprite = new THREE.Sprite(
  80. new THREE.SpriteMaterial({
  81. map: new THREE.TextureLoader().load('textures/video-icon.png'),
  82. color: 0xffffff,
  83. depthWrite: false, //深度写入属性
  84. depthTest: false //depthTest属性值默认true,设置为false可以关闭深度测试.
  85. })
  86. )
  87. sprite.vData = {
  88. id,
  89. RtspMain
  90. }
  91. sprite.position.set(x, y, z)
  92. sprite.scale.set(0.4, 0.45, 0.5)
  93. // 设置精灵的锚点为底部中央
  94. sprite.center.set(0.5, 0)
  95. scene.add(sprite)
  96. sprites.push(sprite)
  97. })
  98. }
  99. provide('addSprite', addSprite)
  100. const raycaster = new THREE.Raycaster()
  101. const pointer = new THREE.Vector2()
  102. const addEvent = (dom = window) => {
  103. dom.addEventListener('click', (event) => {
  104. if (useHomeStore.temp === 'video') return
  105. pointer.x = (event.clientX / window.innerWidth) * 2 - 1
  106. pointer.y = -(event.clientY / window.innerHeight) * 2 + 1
  107. raycaster.setFromCamera(pointer, camera)
  108. const intersects = raycaster.intersectObjects(sprites)
  109. if (intersects.length > 0) {
  110. console.log('标签被点击!', intersects[0].object)
  111. const target = intersects[0].object
  112. translateCamera({ ...camera.position, z: 5 }, target.position, 1).then(() => {
  113. useHomeStore.temp = 'video'
  114. useSystemStore.deviceInfo = target.vData
  115. storage.local.set('d_id', target.vData.id)
  116. emits('onGetData', target.vData)
  117. })
  118. }
  119. })
  120. dom.addEventListener('contextmenu', onRightClick)
  121. }
  122. function onRightClick(event) {
  123. event.preventDefault()
  124. // 隐藏之前的菜单
  125. // document.getElementById('context-menu').style.display = 'none'
  126. // 计算鼠标位置
  127. pointer.x = (event.clientX / window.innerWidth) * 2 - 1
  128. pointer.y = -(event.clientY / window.innerHeight) * 2 + 1
  129. // 更新射线投射
  130. raycaster.setFromCamera(pointer, camera)
  131. // 检测与模型的交点
  132. const intersects = raycaster.intersectObjects(scene.children, true)
  133. if (intersects.length > 0) {
  134. if (intersects[0].object.type === 'Sprite') {
  135. rightClickFlag.value = 'del'
  136. rightClickD.intersection = intersects[0]
  137. } else {
  138. rightClickFlag.value = 'add'
  139. rightClickD.intersection = intersects[0].point
  140. }
  141. // 显示右键菜单
  142. rightClickD.show = true
  143. rightClickD.x = event.clientX / getAutofitScale()
  144. rightClickD.y = event.clientY / getAutofitScale()
  145. // 存储交点信息
  146. } else {
  147. // 如果没有点击到物体,隐藏菜单
  148. rightClickD.show = false
  149. }
  150. }
  151. const destroyedMenu = () => {
  152. rightClickD.show = false
  153. }
  154. document.addEventListener('click', destroyedMenu)
  155. // 创建场景
  156. const scene = new THREE.Scene()
  157. let camera = null
  158. let renderer = null
  159. let cube = null
  160. let axesHelper = null
  161. let controls = null
  162. let loader = null
  163. let heatmapMesh // 用于控制热力图地形的 Mesh
  164. const init = () => {
  165. camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000)
  166. // axesHelper = new THREE.AxesHelper(5)
  167. renderer = new THREE.WebGLRenderer({
  168. canvas: document.getElementById('canvasDom'),
  169. alpha: true
  170. })
  171. camera.position.z = 10
  172. camera.position.y = 2
  173. // camera.position.x = 2;
  174. camera.lookAt(0, 0, 0)
  175. // scene.add(axesHelper)
  176. controls = new OrbitControls(camera, renderer.domElement)
  177. controls.enableDamping = true
  178. controls.dampingFactor = 0.05
  179. // 加载模型
  180. percentageFlag.value = true
  181. // 注意:加载的3d模型 没有灯光是纯黑色 有光才会反射出颜色
  182. loader = new GLTFLoader()
  183. loader.load(
  184. '3d/tiny_city_1k.glb',
  185. function (gltf) {
  186. scene.add(gltf.scene)
  187. percentageFlag.value = false
  188. gltf.scene.scale.x = 0
  189. gltf.scene.scale.y = 0
  190. gltf.scene.scale.z = 0
  191. gltf.scene.rotation.y = 2
  192. gsap.to(gltf.scene.rotation, {
  193. y: 0,
  194. duration: 1,
  195. ease: 'power1.inOut'
  196. })
  197. gsap.to(gltf.scene.scale, {
  198. y: 1,
  199. x: 1,
  200. z: 1,
  201. duration: 1,
  202. ease: 'power1.inOut',
  203. onComplete: async () => {
  204. if (
  205. useSystemStore.permissions &&
  206. !hasPermission(['mrdevicePointShow'], useSystemStore.permissions)
  207. ) {
  208. return
  209. }
  210. const res = await API_MR_CAMERA_GET()
  211. // const sprites = [
  212. // { x: 1.5, y: 0.7, z: 0 },
  213. // { x: 1, y: -0.16, z: 0 },
  214. // { x: -0.5, y: -0.16, z: -1.5 },
  215. // { x: -0.5, y: -0.8, z: 1 }
  216. // ]
  217. const sprites = res.map((item) => ({
  218. x: item.X,
  219. y: item.Y,
  220. z: item.Z,
  221. id: item.Id,
  222. RtspMain: item.RtspMain
  223. }))
  224. addSprite(sprites)
  225. }
  226. })
  227. },
  228. function (xhr) {
  229. percentage.value = Math.floor((xhr.loaded / xhr.total) * 100)
  230. console.log('加载完成的百分比' + (xhr.loaded / xhr.total) * 100 + '%')
  231. },
  232. function (error) {
  233. console.error(error)
  234. }
  235. )
  236. // 加载hdr环境贴图
  237. const rgbeLoader = new RGBELoader()
  238. rgbeLoader.load('textures/sky_linekotsi_01_b_HDRI.hdr', (texture) => {
  239. texture.mapping = THREE.EquirectangularReflectionMapping
  240. scene.environment = texture
  241. scene.background = 'transparent'
  242. })
  243. createHeatmapTerrain()
  244. addEvent(renderer.domElement)
  245. }
  246. let animationFrameID = null
  247. const clock = new THREE.Clock()
  248. function animate() {
  249. // cube.rotation.x += 0.01;
  250. // cube.rotation.y += 0.01;
  251. renderer.render(scene, camera)
  252. controls.update()
  253. animationFrameID = requestAnimationFrame(animate)
  254. }
  255. const resize = () => {
  256. // 更新相机的宽高比
  257. camera.aspect = window.innerWidth / window.innerHeight
  258. camera.updateProjectionMatrix()
  259. // 更新渲染器的尺寸
  260. renderer.setSize(window.innerWidth, window.innerHeight)
  261. renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  262. }
  263. window.addEventListener('resize', resize, false)
  264. // 内存释放
  265. const cleanupThreeJS = () => {
  266. // 停止渲染循环(如果有的话) animationFrameID是requestAnimationFrame的返回值
  267. cancelAnimationFrame(animationFrameID)
  268. // 遍历并清理场景中的所有物体
  269. scene.traverse((child) => {
  270. if (child instanceof THREE.Mesh) {
  271. if (child.geometry?.dispose) child.geometry.dispose()
  272. if (child.material?.dispose) child.material.dispose()
  273. if (child.material?.texture?.dispose) child.material.texture.dispose()
  274. }
  275. if (child instanceof THREE.Group) child.clear()
  276. if (child instanceof THREE.Object3D) child.clear()
  277. })
  278. renderer.dispose() // 如果你的renderer对象有dispose方法的话
  279. }
  280. function generateHeatmapValue(x, z) {
  281. // 增加 scale,使波纹更密集
  282. const scale = 0.4
  283. // 使用两个振幅不同的波叠加,创造更复杂的起伏
  284. const value = Math.sin(x * scale) * 0.8 + Math.cos(z * scale * 1.5) * 1.2
  285. // 我们想要热力值在中心区域更集中、更高。
  286. // 使用高斯函数或类似的衰减函数,使中心点 (0, 0) 附近的值更高。
  287. const distanceSq = x * x + z * z
  288. const centerFactor = Math.exp(-distanceSq * 0.005) // 0.005 控制衰减速度
  289. // 结合起伏和中心集中度
  290. const combinedValue = (value + 2) * 0.5 * centerFactor
  291. // 确保值在 0 到 1 之间
  292. return Math.max(0.0, Math.min(1.0, combinedValue))
  293. }
  294. // 创建热力图地形
  295. function createHeatmapTerrain() {
  296. // *** 调整尺寸: 进一步缩小到 35x35,并增加分段数使曲面更平滑 ***
  297. const width = 35
  298. const height = 35
  299. const widthSegments = 80 // 增加分段数
  300. const heightSegments = 80
  301. const maxTerrainHeight = 4 // 增加地形最大高度,使起伏更剧烈
  302. const geometry = new THREE.PlaneGeometry(width, height, widthSegments, heightSegments)
  303. geometry.rotateX(-Math.PI / 2)
  304. const positionAttribute = geometry.getAttribute('position')
  305. const heatmapValues = []
  306. for (let i = 0; i < positionAttribute.count; i++) {
  307. const x = positionAttribute.getX(i)
  308. const z = positionAttribute.getZ(i)
  309. // 1. 获取热力值 (0 到 1 之间)
  310. const originalHeatValue = generateHeatmapValue(x, z)
  311. // 为了让“红色区域不要太红”,我们对用于着色的热力值做轻微压缩/偏移处理:
  312. // - 将热力值整体缩小到原来的 75%(降低最高端的红色强度)
  313. // - 加上一个小的偏移以避免完全变暗
  314. // - 最终上限设置为 0.95,避免出现完全饱和的红色
  315. const colorHeatValue = Math.min(0.95, Math.max(0.0, originalHeatValue * 0.75 + 0.08))
  316. heatmapValues.push(colorHeatValue)
  317. // 2. 根据原始热力值和坐标生成地形高度(高度仍使用 originalHeatValue,使视觉的高低与热力对应)
  318. const y = originalHeatValue * maxTerrainHeight + 15 // 整体抬高到建筑上方
  319. positionAttribute.setY(i, y)
  320. }
  321. geometry.setAttribute('heatValue', new THREE.Float32BufferAttribute(heatmapValues, 1))
  322. geometry.computeVertexNormals()
  323. // 自定义着色器材质(与上次相同,但确保颜色映射和您图片一致)
  324. const heatmapMaterial = new THREE.ShaderMaterial({
  325. uniforms: {
  326. uTime: { value: 0.0 },
  327. uOpacity: { value: 1.0 } // 全局不透明度控制,可在运行时调整
  328. },
  329. transparent: true, // 允许透明
  330. depthWrite: false, // 关闭深度写入以便正确混合
  331. blending: THREE.NormalBlending,
  332. vertexShader: `
  333. attribute float heatValue;
  334. varying float vHeatValue;
  335. void main() {
  336. vHeatValue = heatValue;
  337. gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  338. }
  339. `,
  340. fragmentShader: `
  341. varying float vHeatValue;
  342. uniform float uOpacity;
  343. void main() {
  344. vec3 color = vec3(0.0);
  345. // 颜色映射逻辑: 确保渐变从深蓝 (最低值) 到红色 (最高值)
  346. if (vHeatValue < 0.25) {
  347. // 深蓝/黑蓝 到 蓝/青 (对应图中最低部分)
  348. color = mix(vec3(0.0, 0.1, 0.4), vec3(0.0, 0.5, 1.0), vHeatValue * 4.0);
  349. } else if (vHeatValue < 0.5) {
  350. // 蓝/青 到 绿色 (对应图中中间部分)
  351. color = mix(vec3(0.0, 0.5, 1.0), vec3(0.0, 1.0, 0.2), (vHeatValue - 0.25) * 4.0);
  352. } else if (vHeatValue < 0.75) {
  353. // 绿色 到 黄色 (对应图中高一些的部分)
  354. color = mix(vec3(0.0, 1.0, 0.2), vec3(1.0, 1.0, 0.0), (vHeatValue - 0.5) * 4.0);
  355. } else {
  356. // 黄色 到 红色/橙色 (对应图中最高峰部分)
  357. color = mix(vec3(1.0, 1.0, 0.0), vec3(1.0, 0.2, 0.0), (vHeatValue - 0.75) * 4.0);
  358. }
  359. // 基于热力值计算每片面的不透明度:低热力值更透明,热值高则更不透明
  360. float minA = 0.08;
  361. float maxA = 0.95;
  362. float localOpacity = mix(minA, maxA, vHeatValue);
  363. // 将局部不透明度与全局控制相乘,方便外部动态调整整体透明度
  364. float finalA = clamp(localOpacity * uOpacity, 0.0, 1.0);
  365. gl_FragColor = vec4(color, finalA);
  366. }
  367. `,
  368. side: THREE.DoubleSide
  369. })
  370. heatmapMesh = new THREE.Mesh(geometry, heatmapMaterial)
  371. heatmapMesh.visible = false
  372. heatmapMesh.scale.set(0.2, 0.2, 0.2)
  373. heatmapMesh
  374. scene.add(heatmapMesh)
  375. }
  376. function toggleHeatmapVisibility(visible) {
  377. if (heatmapMesh) {
  378. heatmapMesh.visible = visible
  379. if (visible) {
  380. translateCamera({ x: 0, y: 10, z: 20 }, { x: 0, y: 0, z: 0 }, 1)
  381. } else {
  382. translateCamera({ x: 0, y: 2, z: 10 }, { x: 0, y: 0, z: 0 }, 1)
  383. }
  384. }
  385. }
  386. watch(
  387. () => useHomeStore.temp,
  388. (newV) => {
  389. if (newV != 'video') {
  390. translateCamera({ x: 0, y: 2, z: 10 }, { x: 0, y: 0, z: 0 }, 1)
  391. }
  392. }
  393. )
  394. $mitt.on('onToolsIndex', (v) => {
  395. if (v.type === 'rlt') {
  396. toggleHeatmapVisibility(v.selected)
  397. }
  398. })
  399. onMounted(() => {
  400. init()
  401. resize()
  402. animate()
  403. })
  404. onUnmounted(() => {
  405. window.removeEventListener('resize', resize)
  406. document.removeEventListener('click', destroyedMenu)
  407. cleanupThreeJS()
  408. })
  409. </script>
  410. <style lang="scss" scoped>
  411. #canvasDom {
  412. // position: fixed;
  413. width: 100%;
  414. height: 100%;
  415. }
  416. </style>