· 5 years ago · Jul 12, 2020, 02:34 PM
1using System;
2using System.Collections;
3using CityGen3D;
4using UnityEngine;
5using UnityEngine.Networking;
6
7#region MIT LICENSE
8
9/*
10 License: The MIT License (MIT)
11 Copyright (C) 2020 Shannon Rowe
12
13 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
14 documentation files (the "Software"), to deal in the Software without restriction, including without limitation
15 the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
16 and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
17
18 The above copyright notice and this permission notice shall be included in all copies or substantial portions of
19 the Software.
20
21 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
22 TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
23 THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
24 CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
25 DEALINGS IN THE SOFTWARE.
26*/
27
28#endregion
29
30#region INSTRUCTIONS
31
32/*
33 Setup:
34
35 1. Attach this script to an empty game object called Player (or similar) to be the player controller.
36 2. Drag or create the main camera as a child under Player, and attach it to "mainCamera" field in the inspector.
37 Set its Clear Flags to Solid Color and its Culling Mask to Everything, and reset all transform values to zero.
38 3. Create a second camera (remove the redundant audio listener) as a child, also under Player,
39 and attach it to "renderCamera" in the inspector for this script.
40 Set its Clear Flags to Skybox and its Culling Mask to Nothing, and again reset transform values to zero.
41 4. Attach the landscape GameObject created by CityGen3D to the "landscape" field in the inspector.
42 5. Add your Google StreetView API key to the relevant field in the inspector.
43 To get one, create a free Google Cloud Platform developer account and enable the Street View Static API.
44 On the free pricing tier, this should get you around 25,000 free tile fetches a month, however it is
45 advisable to regularly keep an eye on your usage using the developer console to make sure you
46 don't inadvertently tip over into the paid threshold. Each panorama used here consists of 6 tiles which
47 are downloaded and then folded up into a cubemap.
48
49 Controller Usage:
50
51 This script is a pretty standard flying camera controller that uses WASD/arrows to fly, shift to sprint,
52 E/PgUp to ascend or Q/PgDn to descend, and the mouse to turn the camera. Speeds are adjustable in the inspector.
53 There's a little red pulsing ball that serves as a crosshairs, but you can turn that off if you prefer.
54 If you left-click, it will teleport to where the crosshairs are pointing (and also triggers loading the
55 StreetView image - more on that below). If you right-click, it will return you to wherever the Player transform
56 was when you first entered play mode, which is useful for setting a starting point to return to.
57 The player starts in free movement mode, meaning no gravity and the ability to clip through colliders; if you
58 press P it will toggle physics on or off (creating the physics components if you don't have any on the player).
59 Lastly, Escape will release the cursor so you can get back to the editor, and left-click will re-enter fly mode.
60
61 StreetView Display usage:
62
63 To open the window, look for it under the CityGen3D menu for "StreetView Display".
64 It's a basic editor window that starts out floating but can be docked or resized as per normal window behaviour.
65 The resizing defaults to keeping the image square to match the render aspect ratio, but you can change the
66 DefaultKeepImageSquare constant in StreetViewWindow if you prefer it to be unconstrained (albeit distorted).
67
68 The magic happens when you enter play mode and fly around and then teleport somewhere with left-click.
69 The Google API will be polled and (assuming you have a StreetView Display window open) the panorama will
70 appear shortly afterwards in the window, and should rotate in sync with your camera movements. Note that the
71 view is set to attempt to match the map's latitude/longitude position to the equivalent Unity position
72 corresponding to roughly where you clicked to teleport, so once you start moving around again, the illusion will
73 quickly be lost. But while you stay in place, you can rotate around and enjoy the synchronised view.
74
75 Technical notes:
76
77 This tool uses the Unity scene skybox to stash the panorama and allow it to be panned around. This will
78 therefore replace any skybox you normally have in your scene. There may be some adjustments that can be made
79 to avoid this, but I haven't explored any of that as this tool isn't intended for use outside the editor anyway.
80 You will also notice seams in many of the panoramas - this occurs due to the fairly crude method used for
81 stitching the tiles together, which would take a better mathematician than myself to resolve.
82 Similarly, I haven't bothered with any of the zoom levels for the tiles that are available in the API, as the
83 results are good enough and Google have resolution limits for the free tier anyway, so while if you resize
84 the window the size of the image will increase, the level of detail will not.
85 Finally, when you left-click somewhere to teleport, you might notice that the player is not always placed
86 exactly at the point you clicked. All Google StreetView panoramas cover a bounding box, so when you click,
87 the API call will first get the nearest panorama to where you clicked, and then take the location stored in
88 the panorama metadata to derive a revised Unity position that approximates the metadata location, and teleports
89 the player controller to that point rather than the point you clicked. In practice, these are usually so
90 close together as to be unnoticeable, however in some sparsely covered regions, you might see larger jumps.
91 The reason for this repositioning is so that your view will more closely be in sync between Unity and StreetView.
92*/
93
94#endregion
95
96public class StreetViewPlayerCamera : MonoBehaviour
97{
98 // This is the approximate height of a Google StreetView car camera (8.2 feet)
99 private const float CameraHeight = 2.49936f;
100 private const PrimitiveType MarkerPrimitive = PrimitiveType.Sphere;
101 private const string MarkerObjectName = "Marker";
102 private const float MarkerScaleSpeed = 10f;
103 private const float MarkerScaleAmount = 0.015f;
104 private const float MarkerRotationSpeed = 100f;
105 private const float MarkerPositionOffset = 0.25f;
106 private const int NumberSides = 6;
107 private const int TileWidth = 400;
108 private const int TileHeight = 400;
109 private const int Fov = 90;
110 private const float WaitTimeout = 10f;
111 private const string DoublePrecision = "F6";
112 private const float DefaultCapsuleColliderHeight = 3f;
113 private const float DefaultCapsuleColliderRadius = 2f;
114 private const CollisionDetectionMode DefaultCollisionDetectionMode = CollisionDetectionMode.Discrete;
115
116 [SerializeField] private string streetViewApiKey;
117 [SerializeField] private Camera mainCamera;
118 [SerializeField] private Camera renderCamera;
119 [SerializeField] private Landscape landscape;
120 [SerializeField] private float walkSpeed = 100f;
121 [SerializeField] private float runSpeed = 250f;
122 [SerializeField] private float rotationSpeed = 4f;
123 [SerializeField] private bool invertYAxis;
124 [SerializeField] private bool showMarker = true;
125 [SerializeField] private Color markerColor = Color.red;
126
127 public delegate void ImageLoadedDelegate (LoadChain chain);
128
129 public static ImageLoadedDelegate onImageLoaded;
130
131 private Transform _trans;
132 private Transform _mainCameraTrans;
133 private Transform _renderCameraTrans;
134 private Transform _markerTrans;
135 private Collider _collider;
136 private Rigidbody _rigidbody;
137 private Vector2 _currentRotation;
138 private Vector2 _restartRotation;
139 private Vector3 _restartPosition;
140 private GameObject _marker;
141 private readonly int _frontTex = Shader.PropertyToID ("_FrontTex");
142 private readonly int _leftTex = Shader.PropertyToID ("_LeftTex");
143 private readonly int _backTex = Shader.PropertyToID ("_BackTex");
144 private readonly int _rightTex = Shader.PropertyToID ("_RightTex");
145 private readonly int _upTex = Shader.PropertyToID ("_UpTex");
146 private readonly int _downTex = Shader.PropertyToID ("_DownTex");
147 private bool _isWorkingOnMetadata;
148 private bool _isWorkingOnPanorama;
149 private int _fetchesDone;
150 private float _timeoutTimer;
151 private LoadChain _loadChain;
152 private Action _metadataCallbackAction;
153 private Action _imageCallbackAction;
154 private readonly object _lockObject = new object ();
155
156 private void Awake ()
157 {
158 _trans = transform;
159 _mainCameraTrans = mainCamera.transform;
160 _renderCameraTrans = renderCamera.transform;
161 _collider = GetComponent<Collider> ();
162 if (_collider == null)
163 {
164 _collider = gameObject.AddComponent<CapsuleCollider> ();
165 ((CapsuleCollider) _collider).height = DefaultCapsuleColliderHeight;
166 ((CapsuleCollider) _collider).radius = DefaultCapsuleColliderRadius;
167 }
168
169 _rigidbody = GetComponent<Rigidbody> ();
170 if (_rigidbody == null)
171 {
172 _rigidbody = gameObject.AddComponent<Rigidbody> ();
173 _rigidbody.freezeRotation = true;
174 _rigidbody.collisionDetectionMode = DefaultCollisionDetectionMode;
175 }
176
177 _collider.enabled = false;
178 _rigidbody.isKinematic = true;
179
180 _currentRotation = _mainCameraTrans.eulerAngles / rotationSpeed;
181 _restartRotation = _currentRotation;
182 _restartPosition = _trans.position;
183 Cursor.lockState = CursorLockMode.Locked;
184
185 if (showMarker)
186 {
187 _marker = GameObject.CreatePrimitive (MarkerPrimitive);
188 _marker.name = MarkerObjectName;
189 _marker.GetComponent<Renderer> ().sharedMaterial.color = markerColor;
190 _marker.GetComponent<Collider> ().enabled = false;
191 _markerTrans = _marker.transform;
192 }
193
194 renderCamera.targetTexture = new RenderTexture (TileWidth, TileHeight, 24);
195 }
196
197 private void Update ()
198 {
199 if (Cursor.lockState == CursorLockMode.None)
200 {
201 if (Input.GetButtonDown ("Fire1"))
202 {
203 Cursor.lockState = CursorLockMode.Locked;
204 }
205
206 return;
207 }
208
209 if (Input.GetKeyDown (KeyCode.Escape))
210 {
211 Cursor.lockState = CursorLockMode.None;
212 return;
213 }
214
215 if (Input.GetButtonDown ("Fire2"))
216 {
217 _currentRotation = _restartRotation;
218 _mainCameraTrans.eulerAngles = _currentRotation * rotationSpeed;
219 _renderCameraTrans.eulerAngles = _currentRotation * rotationSpeed;
220 _trans.position = _restartPosition;
221 return;
222 }
223
224 Ray ray = mainCamera.ScreenPointToRay (Input.mousePosition);
225 if (!Physics.Raycast (ray.origin, _mainCameraTrans.forward * mainCamera.farClipPlane, out RaycastHit hit))
226 {
227 if (showMarker)
228 {
229 _marker.SetActive (false);
230 }
231 }
232 else
233 {
234 if (showMarker)
235 {
236 _marker.SetActive (true);
237 float currentDistance = Vector3.Distance (_trans.position, hit.point);
238 float adjustScale = Mathf.Sin (Time.time * MarkerScaleSpeed) * MarkerScaleAmount;
239 float finalScale = currentDistance * adjustScale;
240 _markerTrans.localScale = new Vector3 (finalScale, finalScale, finalScale);
241 _markerTrans.position =
242 new Vector3 (hit.point.x, hit.point.y, hit.point.z) -
243 _mainCameraTrans.forward * (Mathf.Abs (finalScale) * MarkerPositionOffset);
244 _markerTrans.Rotate (Vector3.up, Time.deltaTime * MarkerRotationSpeed);
245 }
246
247 if (Input.GetButtonDown ("Fire1"))
248 {
249 // Jutting out perpendicularly a little bit by the normal helps avoid clipping
250 _trans.position = hit.point + hit.normal * CameraHeight;
251 _mainCameraTrans.LookAt (hit.point);
252 _renderCameraTrans.LookAt (hit.point);
253 TeleportToPosition (_trans.position);
254 }
255 }
256
257 if (Input.GetKeyDown (KeyCode.P))
258 {
259 _collider.enabled = !_collider.enabled;
260 _rigidbody.isKinematic = !_rigidbody.isKinematic;
261 }
262
263 bool isPressingPageUpKey = Input.GetKey (KeyCode.PageUp) || Input.GetKey (KeyCode.E);
264 bool isPressingPageDownKey = Input.GetKey (KeyCode.PageDown) || Input.GetKey (KeyCode.Q);
265 bool isPressingRunKey = Input.GetKey (KeyCode.LeftShift) || Input.GetKey (KeyCode.RightShift);
266
267 float movementSpeed = Time.deltaTime * (isPressingRunKey ? runSpeed : walkSpeed);
268 float x = Input.GetAxisRaw ("Horizontal") * movementSpeed;
269 float z = Input.GetAxisRaw ("Vertical") * movementSpeed;
270 float y = isPressingPageUpKey ? movementSpeed : isPressingPageDownKey ? -movementSpeed : 0f;
271
272 // Need to temporarily set rotation to the same as camera, so .forward is correct
273 Quaternion tempRot = _trans.rotation;
274 _trans.rotation = _mainCameraTrans.rotation;
275 _trans.Translate (x, y, z, Space.Self);
276 _trans.Translate (0f, y, 0f, Space.World);
277 // ReSharper disable once Unity.InefficientPropertyAccess
278 _trans.rotation = tempRot;
279
280 _currentRotation.y += Input.GetAxis ("Mouse X");
281 _currentRotation.x -= Input.GetAxis ("Mouse Y") * (invertYAxis ? -1f : 1f);
282 _mainCameraTrans.eulerAngles = _currentRotation * rotationSpeed;
283 _renderCameraTrans.eulerAngles = _currentRotation * rotationSpeed;
284 }
285
286 private void TeleportToPosition (Vector3 position)
287 {
288 if (_isWorkingOnMetadata || _isWorkingOnPanorama)
289 {
290 Debug.LogError ("Already working on another panorama.");
291 return;
292 }
293
294 _loadChain = new LoadChain {location = landscape.origin.GetLocation (position.x, position.z)};
295
296 lock (_lockObject)
297 {
298 _isWorkingOnMetadata = true;
299 _metadataCallbackAction = CallbackFetchedMetadata;
300
301 string url = "https://maps.googleapis.com/maps/api/streetview/metadata?" +
302 "key=" + streetViewApiKey +
303 "&size=" + TileWidth + "x" + TileHeight +
304 "&location=" + _loadChain.location.latitude.ToString (DoublePrecision) + "," +
305 _loadChain.location.longitude.ToString (DoublePrecision) +
306 "&heading=" + 0d +
307 "&pitch=" + 0d +
308 "&fov=" + Fov +
309 "&sensor=false";
310
311 StartCoroutine (FetchJsonClass<PanoObject> (url, CallbackFetchedMetadataJson));
312 }
313 }
314
315 private void CallbackFetchedMetadataJson (bool success, PanoObject panorama, string message)
316 {
317 if (!success)
318 {
319 _isWorkingOnMetadata = false;
320 _loadChain.success = false;
321 _loadChain.panorama = panorama;
322 _loadChain.message = message;
323 _metadataCallbackAction ();
324 return;
325 }
326
327 if (panorama.status != "OK")
328 {
329 _isWorkingOnMetadata = false;
330 _loadChain.success = false;
331 _loadChain.panorama = panorama;
332 _loadChain.message = panorama.status;
333 _metadataCallbackAction ();
334 return;
335 }
336
337 _isWorkingOnMetadata = false;
338 _loadChain.panorama = panorama;
339 _metadataCallbackAction ();
340 }
341
342 private void CallbackFetchedMetadata ()
343 {
344 if (!_loadChain.success)
345 {
346 onImageLoaded?.Invoke (_loadChain);
347 return;
348 }
349
350 lock (_lockObject)
351 {
352 _isWorkingOnPanorama = true;
353 Shader skyboxShader = Shader.Find ("Skybox/6 Sided");
354 _loadChain.material = new Material (skyboxShader);
355 _imageCallbackAction = CallbackFetchedStreetView;
356 _timeoutTimer = Time.time + WaitTimeout;
357 _fetchesDone = 0;
358
359 RequestPanoramaImage (_loadChain.location, 0d, 0d, CallbackPanoramaImageFront);
360 RequestPanoramaImage (_loadChain.location, 90d, 0d, CallbackPanoramaImageLeft);
361 RequestPanoramaImage (_loadChain.location, 180d, 0d, CallbackPanoramaImageBack);
362 RequestPanoramaImage (_loadChain.location, 270d, 0d, CallbackPanoramaImageRight);
363 RequestPanoramaImage (_loadChain.location, 0d, 90d, CallbackPanoramaImageUp);
364 RequestPanoramaImage (_loadChain.location, 0d, -90d, CallbackPanoramaImageDown);
365 }
366 }
367
368 private void RequestPanoramaImage (
369 GeoCoord location, double heading, double pitch, Action<bool, Texture2D, string> callback)
370 {
371 string url = "https://maps.googleapis.com/maps/api/streetview?" +
372 "key=" + streetViewApiKey +
373 "&size=" + TileWidth + "x" + TileHeight +
374 "&location=" + location.latitude + "," + location.longitude +
375 "&heading=" + heading +
376 "&pitch=" + pitch +
377 "&fov=" + Fov +
378 "&sensor=false";
379
380 StartCoroutine (FetchImage (url, callback));
381 }
382
383 private void CallbackPanoramaImage (int side, bool success, Texture result, string message)
384 {
385 lock (_lockObject)
386 {
387 if (!success)
388 {
389 _isWorkingOnPanorama = false;
390 _loadChain.success = false;
391 _loadChain.message = message;
392 return;
393 }
394
395 result.wrapMode = TextureWrapMode.Clamp;
396 _loadChain.material.SetTexture (side, result);
397 _fetchesDone++;
398
399 if (Time.time > _timeoutTimer)
400 {
401 _isWorkingOnPanorama = false;
402 _loadChain.success = false;
403 _loadChain.message = "Attempt to fetch StreetView panorama timed out";
404 }
405
406 if (!_loadChain.success)
407 {
408 _imageCallbackAction ();
409 return;
410 }
411
412 if (_fetchesDone < NumberSides)
413 {
414 return;
415 }
416
417 _imageCallbackAction ();
418 _isWorkingOnPanorama = false;
419 }
420 }
421
422 private void CallbackPanoramaImageFront (bool success, Texture2D texture, string message)
423 {
424 CallbackPanoramaImage (_frontTex, success, texture, message);
425 }
426
427 private void CallbackPanoramaImageLeft (bool success, Texture2D texture, string message)
428 {
429 CallbackPanoramaImage (_leftTex, success, texture, message);
430 }
431
432 private void CallbackPanoramaImageBack (bool success, Texture2D texture, string message)
433 {
434 CallbackPanoramaImage (_backTex, success, texture, message);
435 }
436
437 private void CallbackPanoramaImageRight (bool success, Texture2D texture, string message)
438 {
439 CallbackPanoramaImage (_rightTex, success, texture, message);
440 }
441
442 private void CallbackPanoramaImageUp (bool success, Texture2D texture, string message)
443 {
444 CallbackPanoramaImage (_upTex, success, texture, message);
445 }
446
447 private void CallbackPanoramaImageDown (bool success, Texture2D texture, string message)
448 {
449 CallbackPanoramaImage (_downTex, success, texture, message);
450 }
451
452 private void CallbackFetchedStreetView ()
453 {
454 if (!_loadChain.success)
455 {
456 onImageLoaded?.Invoke (_loadChain);
457 return;
458 }
459
460 RenderSettings.skybox = _loadChain.material;
461
462 GeoCoord revisedLocation = new GeoCoord (_loadChain.panorama.location.lat, _loadChain.panorama.location.lng);
463 Vector3 revisedPosition = revisedLocation.GetMapCoord (landscape.origin).GetPosition ();
464 Vector3 pointAboveTerrain = revisedPosition;
465 pointAboveTerrain.y -= 5000;
466 Vector3 pointBelowTerrain = new Vector3 (pointAboveTerrain.x, pointAboveTerrain.y + 10000, pointAboveTerrain.z);
467 Vector3 rayDirection = pointAboveTerrain - pointBelowTerrain;
468 Ray ray = new Ray (pointBelowTerrain, rayDirection);
469 bool didHitTerrain = Physics.Raycast (ray, out RaycastHit hit, Mathf.Infinity);
470 if (!didHitTerrain)
471 {
472 return;
473 }
474
475 revisedPosition = hit.point;
476 revisedPosition.y += CameraHeight;
477 _trans.position = revisedPosition;
478
479 _loadChain.success = true;
480 _loadChain.message = revisedLocation.ToString ();
481 _loadChain.renderTexture = renderCamera.targetTexture;
482 onImageLoaded?.Invoke (_loadChain);
483 }
484
485 private static IEnumerator FetchJsonClass<T> (string url, Action<bool, T, string> callback)
486 {
487 bool success = true;
488 string message = null;
489
490 UnityWebRequest www = UnityWebRequest.Get (url);
491 yield return www.SendWebRequest ();
492
493 if (www.isHttpError || www.isNetworkError || !string.IsNullOrEmpty (www.error))
494 {
495 success = false;
496 message = "Network error " + www.error + " attempting to fetch URL " + url;
497 }
498
499 T result = JsonUtility.FromJson<T> (www.downloadHandler.text);
500 if (result == null)
501 {
502 success = false;
503 message = "No JSON data could be parsed from URL " + url;
504 }
505
506 callback?.Invoke (success, result, message);
507 }
508
509 private static IEnumerator FetchImage (string url, Action<bool, Texture2D, string> callback)
510 {
511 bool success = true;
512 string message = null;
513
514 UnityWebRequest www = UnityWebRequestTexture.GetTexture (url);
515 yield return www.SendWebRequest ();
516
517 if (www.isHttpError || www.isNetworkError || !string.IsNullOrEmpty (www.error))
518 {
519 success = false;
520 message = "Network error " + www.error + " attempting to fetch URL " + url;
521 }
522
523 Texture2D result = ((DownloadHandlerTexture) www.downloadHandler).texture;
524 if (result == null)
525 {
526 success = false;
527 message = "No texture data could be parsed from URL " + url;
528 }
529
530 callback?.Invoke (success, result, message);
531 }
532
533 // These names are required to be in these formats by the JSON conversion and can't be changed
534 // ReSharper disable IdentifierTypo
535 // ReSharper disable StringLiteralTypo
536 // ReSharper disable NotAccessedField.Global
537 // ReSharper disable InconsistentNaming
538 [Serializable]
539 public class PanoObject
540 {
541 public string copyright;
542 public string date;
543 public Location location;
544 public string pano_id;
545 public string status;
546 }
547 // ReSharper restore InconsistentNaming
548 // ReSharper restore NotAccessedField.Global
549 // ReSharper restore StringLiteralTypo
550 // ReSharper restore IdentifierTypo
551
552 // These names are required to be in these formats by the JSON conversion and can't be changed
553 [Serializable]
554 public class Location
555 {
556 public double lat;
557 public double lng;
558
559 public override string ToString ()
560 {
561 return lat + "," + lng;
562 }
563 }
564
565 [Serializable]
566 public class LoadChain
567 {
568 public bool success;
569 public GeoCoord location;
570 public PanoObject panorama;
571 public RenderTexture renderTexture;
572 public Material material;
573 public string message;
574
575 public LoadChain ()
576 {
577 success = true;
578 }
579 }
580}