Repository: yasirkula/Unity360ScreenshotCapture Branch: master Commit: 9e08fe7e0135 Files: 16 Total size: 20.9 KB Directory structure: gitextract_9lxfcvwc/ ├── .github/ │ └── README.md ├── LICENSE.txt ├── LICENSE.txt.meta ├── Plugins/ │ ├── Simple360Render/ │ │ ├── I360Render.cs │ │ ├── I360Render.cs.meta │ │ ├── README.txt │ │ ├── README.txt.meta │ │ ├── Resources/ │ │ │ ├── EquirectangularConverter.shader │ │ │ └── EquirectangularConverter.shader.meta │ │ ├── Resources.meta │ │ ├── Simple360Render.Runtime.asmdef │ │ └── Simple360Render.Runtime.asmdef.meta │ └── Simple360Render.meta ├── Plugins.meta ├── package.json └── package.json.meta ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/README.md ================================================ # Unity 360° Screenshot Capture **Available on Asset Store:** https://assetstore.unity.com/packages/tools/camera/360-screenshot-capture-112864 **Forum Thread:** https://forum.unity.com/threads/360-screenshot-capture-open-source.501310/ **Discord:** https://discord.gg/UJJt549AaV **[GitHub Sponsors ☕](https://github.com/sponsors/yasirkula)** This simple script captures a **360° photo** with your Unity camera and injects the necessary **XMP metadata** to it; so the output image supports 360° viewers on the web out-of-the-box (like *Facebook* and *Flickr*). Both **JPEG** and **PNG** formats are supported. The raw image is in equirectangular form. Here is an example screenshot [(it looks like this when uploaded to Flickr)](https://flic.kr/p/VPxPwY): ![screenshot](Images/360render.jpeg) ## INSTALLATION There are 5 ways to install this plugin: - import [360Screenshot.unitypackage](https://github.com/yasirkula/Unity360ScreenshotCapture/releases) via *Assets-Import Package* - clone/[download](https://github.com/yasirkula/Unity360ScreenshotCapture/archive/master.zip) this repository and move the *Plugins* folder to your Unity project's *Assets* folder - import it from [Asset Store](https://assetstore.unity.com/packages/tools/camera/360-screenshot-capture-112864) - *(via Package Manager)* click the + button and install the package from the following git URL: - `https://github.com/yasirkula/Unity360ScreenshotCapture.git` - *(via [OpenUPM](https://openupm.com))* after installing [openupm-cli](https://github.com/openupm/openupm-cli), run the following command: - `openupm add com.yasirkula.screenshotcapture` ## HOW TO Simply call the `I360Render.Capture()` or `I360Render.CaptureAsync()` (Unity 2018.2 or later) function in your scripts. Their signatures are as follows: ```csharp public static byte[] Capture( int width = 1024, bool encodeAsJPEG = true, Camera renderCam = null, bool faceCameraDirection = true ); // !!! Async version uses AsyncGPUReadback.Request so it won't work on all platforms or Graphics APIs !!! public static void CaptureAsync( Action callback, int width = 1024, bool encodeAsJPEG = true, Camera renderCam = null, bool faceCameraDirection = true ); ``` - **width**: The width of the resulting image. It must be a power of 2. The height will be equal to *width / 2*. Be aware that maximum allowed image width is 8192 pixels - **encodeAsJPEG**: determines whether the image will be encoded as *JPEG* or *PNG* - **renderCam**: the camera that will be used to render the 360° image. If set to null, *Camera.main* will be used - **faceCameraDirection**: if set to *true*, when the 360° image is viewed in a 360° viewer, initial camera rotation will match the rotation of the *renderCam*. Otherwise, initial camera rotation will be *Quaternion.identity* (facing Z+ axis) These functions return a **byte[]** object either directly or as a callback; you can write these bytes to a file using `File.WriteAllBytes` (see example code below). ## FAQ - **Objects are rendered inside out in the 360° screenshot** This is usually caused by 3rd-party plugins that change the value of `GL.invertCulling` (e.g. mirrors). See the solution: https://forum.unity.com/threads/360-screenshot-capture-open-source.501310/#post-7078093 - **360° screenshot is blank on Oculus Quest 2** Try using the `CaptureAsync` function instead of `Capture`. ## EXAMPLE CODE ```csharp using System.IO; using UnityEngine; public class RenderTest : MonoBehaviour { public int imageWidth = 1024; public bool saveAsJPEG = true; void Update() { if( Input.GetKeyDown( KeyCode.P ) ) { byte[] bytes = I360Render.Capture( imageWidth, saveAsJPEG ); if( bytes != null ) { string path = Path.Combine( Application.persistentDataPath, "360render" + ( saveAsJPEG ? ".jpeg" : ".png" ) ); File.WriteAllBytes( path, bytes ); Debug.Log( "360 render saved to " + path ); } } } } ``` ================================================ FILE: LICENSE.txt ================================================ MIT License Copyright (c) 2020 Süleyman Yasir KULA Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: LICENSE.txt.meta ================================================ fileFormatVersion: 2 guid: 308ff69723b9a1f4f9cf961b4d1c04bc TextScriptImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: Plugins/Simple360Render/I360Render.cs ================================================ using System; using UnityEngine; using UnityEngine.Rendering; public static class I360Render { private static Material equirectangularConverter = null; private static int paddingX; public static byte[] Capture( int width = 1024, bool encodeAsJPEG = true, Camera renderCam = null, bool faceCameraDirection = true ) { return CaptureInternal( width, encodeAsJPEG, renderCam, faceCameraDirection ); } public static void CaptureAsync( Action callback, int width = 1024, bool encodeAsJPEG = true, Camera renderCam = null, bool faceCameraDirection = true ) { CaptureInternal( width, encodeAsJPEG, renderCam, faceCameraDirection, callback ); } private static byte[] CaptureInternal( int width = 1024, bool encodeAsJPEG = true, Camera renderCam = null, bool faceCameraDirection = true, Action asyncCallback = null ) { if( renderCam == null ) { renderCam = Camera.main; if( renderCam == null ) { Debug.LogError( "Error: no camera detected" ); if( asyncCallback != null ) asyncCallback( null ); return null; } } RenderTexture camTarget = renderCam.targetTexture; if( equirectangularConverter == null ) { equirectangularConverter = new Material( Shader.Find( "Hidden/I360CubemapToEquirectangular" ) ); paddingX = Shader.PropertyToID( "_PaddingX" ); } bool asyncOperationStarted = false; int cubemapSize = Mathf.Min( Mathf.NextPowerOfTwo( width ), 8192 ); RenderTexture activeRT = RenderTexture.active; RenderTexture cubemap = null, equirectangularTexture = null; Texture2D output = null; try { RenderTextureDescriptor desc = new(cubemapSize, cubemapSize, RenderTextureFormat.ARGB32, 0) { dimension = TextureDimension.Cube }; cubemap = RenderTexture.GetTemporary(desc); equirectangularTexture = RenderTexture.GetTemporary(cubemapSize, cubemapSize / 2, 0); if( !renderCam.RenderToCubemap( cubemap, 63 ) ) { Debug.LogError( "Rendering to cubemap is not supported on device/platform!" ); if( asyncCallback != null ) asyncCallback( null ); return null; } equirectangularConverter.SetFloat( paddingX, faceCameraDirection ? ( renderCam.transform.eulerAngles.y / 360f ) : 0f ); Graphics.Blit( cubemap, equirectangularTexture, equirectangularConverter ); if( asyncCallback != null ) { AsyncGPUReadback.Request( equirectangularTexture, 0, TextureFormat.RGB24, ( asyncResult ) => { try { output = new Texture2D( equirectangularTexture.width, equirectangularTexture.height, TextureFormat.RGB24, false ); if( !asyncResult.hasError ) output.LoadRawTextureData( asyncResult.GetData() ); else { Debug.LogError( "Async thumbnail request failed, falling back to conventional method" ); RenderTexture _activeRT = RenderTexture.active; try { RenderTexture.active = equirectangularTexture; output.ReadPixels( new Rect( 0, 0, equirectangularTexture.width, equirectangularTexture.height ), 0, 0 ); } finally { RenderTexture.active = _activeRT; } } asyncCallback( encodeAsJPEG ? InsertXMPIntoTexture2D_JPEG( output ) : InsertXMPIntoTexture2D_PNG( output ) ); } finally { if( equirectangularTexture ) RenderTexture.ReleaseTemporary( equirectangularTexture ); if( output ) UnityEngine.Object.DestroyImmediate( output ); } } ); asyncOperationStarted = true; return null; } else { RenderTexture.active = equirectangularTexture; output = new Texture2D( equirectangularTexture.width, equirectangularTexture.height, TextureFormat.RGB24, false ); output.ReadPixels( new Rect( 0, 0, equirectangularTexture.width, equirectangularTexture.height ), 0, 0 ); return encodeAsJPEG ? InsertXMPIntoTexture2D_JPEG( output ) : InsertXMPIntoTexture2D_PNG( output ); } } catch( Exception e ) { Debug.LogException( e ); if( !asyncOperationStarted && asyncCallback != null ) asyncCallback( null ); return null; } finally { renderCam.targetTexture = camTarget; if( !asyncOperationStarted ) RenderTexture.active = activeRT; if( cubemap ) RenderTexture.ReleaseTemporary( cubemap ); if( equirectangularTexture ) { if( !asyncOperationStarted ) RenderTexture.ReleaseTemporary( equirectangularTexture ); } if( output ) { if( !asyncOperationStarted ) UnityEngine.Object.DestroyImmediate( output ); } } } #region XMP Injection private const string XMP_NAMESPACE_JPEG = "http://ns.adobe.com/xap/1.0/"; private const string XMP_CONTENT_TO_FORMAT_JPEG = " "; private const string XMP_CONTENT_TO_FORMAT_PNG = "XML:com.adobe.xmp\0\0\0\0\0 True Unity3D Unity3D equirectangular 180.0 0.0 0.0 0.0 {0} 0 0 {1} {2} {1} {2} 1 {1} {2} "; private static uint[] CRC_TABLE_PNG = null; public static byte[] InsertXMPIntoTexture2D_JPEG( Texture2D image ) { return DoTheHardWork_JPEG( image.EncodeToJPG( 100 ), image.width, image.height ); } public static byte[] InsertXMPIntoTexture2D_PNG( Texture2D image ) { return DoTheHardWork_PNG( image.EncodeToPNG(), image.width, image.height ); } #region JPEG Encoding private static byte[] DoTheHardWork_JPEG( byte[] fileBytes, int imageWidth, int imageHeight ) { int xmpIndex = 0, xmpContentSize = 0; while( !SearchChunkForXMP_JPEG( fileBytes, ref xmpIndex, ref xmpContentSize ) ) { if( xmpIndex == -1 ) break; } int copyBytesUntil, copyBytesFrom; if( xmpIndex == -1 ) { copyBytesUntil = copyBytesFrom = FindIndexToInsertXMPCode_JPEG( fileBytes ); } else { copyBytesUntil = xmpIndex; copyBytesFrom = xmpIndex + 2 + xmpContentSize; } string xmpContent = string.Concat( XMP_NAMESPACE_JPEG, "\0", string.Format( XMP_CONTENT_TO_FORMAT_JPEG, 75f.ToString( "F1" ), imageWidth, imageHeight ) ); int xmpLength = xmpContent.Length + 2; xmpContent = string.Concat( (char) 0xFF, (char) 0xE1, (char) ( xmpLength / 256 ), (char) ( xmpLength % 256 ), xmpContent ); byte[] result = new byte[copyBytesUntil + xmpContent.Length + ( fileBytes.Length - copyBytesFrom )]; Array.Copy( fileBytes, 0, result, 0, copyBytesUntil ); for( int i = 0; i < xmpContent.Length; i++ ) { result[copyBytesUntil + i] = (byte) xmpContent[i]; } Array.Copy( fileBytes, copyBytesFrom, result, copyBytesUntil + xmpContent.Length, fileBytes.Length - copyBytesFrom ); return result; } private static bool CheckBytesForXMPNamespace_JPEG( byte[] bytes, int startIndex ) { for( int i = 0; i < XMP_NAMESPACE_JPEG.Length; i++ ) { if( bytes[startIndex + i] != XMP_NAMESPACE_JPEG[i] ) return false; } return true; } private static bool SearchChunkForXMP_JPEG( byte[] bytes, ref int startIndex, ref int chunkSize ) { if( startIndex + 4 < bytes.Length ) { //Debug.Log( startIndex + " " + System.Convert.ToByte( bytes[startIndex] ).ToString( "x2" ) + " " + System.Convert.ToByte( bytes[startIndex+1] ).ToString( "x2" ) + " " + // System.Convert.ToByte( bytes[startIndex+2] ).ToString( "x2" ) + " " + System.Convert.ToByte( bytes[startIndex+3] ).ToString( "x2" ) ); if( bytes[startIndex] == 0xFF ) { byte secondByte = bytes[startIndex + 1]; if( secondByte == 0xDA ) { startIndex = -1; return false; } else if( secondByte == 0x01 || ( secondByte >= 0xD0 && secondByte <= 0xD9 ) ) { startIndex += 2; return false; } else { chunkSize = bytes[startIndex + 2] * 256 + bytes[startIndex + 3]; if( secondByte == 0xE1 && chunkSize >= 31 && CheckBytesForXMPNamespace_JPEG( bytes, startIndex + 4 ) ) { return true; } startIndex = startIndex + 2 + chunkSize; } } } return false; } private static int FindIndexToInsertXMPCode_JPEG( byte[] bytes ) { int chunkSize = bytes[4] * 256 + bytes[5]; return chunkSize + 4; } #endregion #region PNG Encoding private static byte[] DoTheHardWork_PNG( byte[] fileBytes, int imageWidth, int imageHeight ) { string xmpContent = "iTXt" + string.Format( XMP_CONTENT_TO_FORMAT_PNG, 75f.ToString( "F1" ), imageWidth, imageHeight ); int copyBytesUntil = 33; int xmpLength = xmpContent.Length - 4; // minus iTXt string xmpCRC = CalculateCRC_PNG( xmpContent ); xmpContent = string.Concat( (char) ( xmpLength >> 24 ), (char) ( xmpLength >> 16 ), (char) ( xmpLength >> 8 ), (char) ( xmpLength ), xmpContent, xmpCRC ); byte[] result = new byte[fileBytes.Length + xmpContent.Length]; Array.Copy( fileBytes, 0, result, 0, copyBytesUntil ); for( int i = 0; i < xmpContent.Length; i++ ) { result[copyBytesUntil + i] = (byte) xmpContent[i]; } Array.Copy( fileBytes, copyBytesUntil, result, copyBytesUntil + xmpContent.Length, fileBytes.Length - copyBytesUntil ); return result; } // Source: https://github.com/damieng/DamienGKit/blob/master/CSharp/DamienG.Library/Security/Cryptography/Crc32.cs private static string CalculateCRC_PNG( string xmpContent ) { if( CRC_TABLE_PNG == null ) CalculateCRCTable_PNG(); uint crc = ~UpdateCRC_PNG( xmpContent ); byte[] crcBytes = CalculateCRCBytes_PNG( crc ); return string.Concat( (char) crcBytes[0], (char) crcBytes[1], (char) crcBytes[2], (char) crcBytes[3] ); } private static uint UpdateCRC_PNG( string xmpContent ) { uint c = 0xFFFFFFFF; for( int i = 0; i < xmpContent.Length; i++ ) { c = ( c >> 8 ) ^ CRC_TABLE_PNG[xmpContent[i] ^ c & 0xFF]; } return c; } private static void CalculateCRCTable_PNG() { CRC_TABLE_PNG = new uint[256]; for( uint i = 0; i < 256; i++ ) { uint c = i; for( int j = 0; j < 8; j++ ) { if( ( c & 1 ) == 1 ) c = ( c >> 1 ) ^ 0xEDB88320; else c = ( c >> 1 ); } CRC_TABLE_PNG[i] = c; } } private static byte[] CalculateCRCBytes_PNG( uint crc ) { var result = BitConverter.GetBytes( crc ); if( BitConverter.IsLittleEndian ) Array.Reverse( result ); return result; } #endregion #endregion } ================================================ FILE: Plugins/Simple360Render/I360Render.cs.meta ================================================ fileFormatVersion: 2 guid: 801b5200ce5a97c45be851954572218b timeCreated: 1498249486 licenseType: Free MonoImporter: serializedVersion: 2 defaultReferences: [] executionOrder: 0 icon: {instanceID: 0} userData: assetBundleName: assetBundleVariant: ================================================ FILE: Plugins/Simple360Render/README.txt ================================================ = 360° Screenshot Capture (v1.1.1) = Documentation: https://github.com/yasirkula/Unity360ScreenshotCapture FAQ: https://github.com/yasirkula/Unity360ScreenshotCapture#faq Example code: https://github.com/yasirkula/Unity360ScreenshotCapture#example-code E-mail: yasirkula@gmail.com ================================================ FILE: Plugins/Simple360Render/README.txt.meta ================================================ fileFormatVersion: 2 guid: 639da580aad6c4d4fb7e979a1f626fa6 timeCreated: 1563308755 licenseType: Free TextScriptImporter: userData: assetBundleName: assetBundleVariant: ================================================ FILE: Plugins/Simple360Render/Resources/EquirectangularConverter.shader ================================================ // Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)' // Credit: https://github.com/Mapiarz/CubemapToEquirectangular/blob/master/Assets/Shaders/CubemapToEquirectangular.shader Shader "Hidden/I360CubemapToEquirectangular" { Properties { _MainTex ("Cubemap (RGB)", CUBE) = "" {} _PaddingX ("Padding X", Float) = 0.0 } Subshader { Pass { ZTest Always Cull Off ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma fragmentoption ARB_precision_hint_fastest //#pragma fragmentoption ARB_precision_hint_nicest #include "UnityCG.cginc" #define PI 3.141592653589793 #define TWOPI 6.283185307179587 struct v2f { float4 pos : POSITION; float2 uv : TEXCOORD0; }; samplerCUBE _MainTex; float _PaddingX; v2f vert(appdata_img v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = (v.texcoord.xy + float2(_PaddingX,0)) * float2(TWOPI, PI); return o; } fixed4 frag(v2f i) : COLOR { float theta = i.uv.y; float phi = i.uv.x; float3 unit = float3(0,0,0); unit.x = sin(phi) * sin(theta) * -1; unit.y = cos(theta) * -1; unit.z = cos(phi) * sin(theta) * -1; return texCUBE(_MainTex, unit); } ENDCG } } Fallback Off } ================================================ FILE: Plugins/Simple360Render/Resources/EquirectangularConverter.shader.meta ================================================ fileFormatVersion: 2 guid: 73817e9d29a0033499fbf28be55f2120 timeCreated: 1498250385 licenseType: Free ShaderImporter: defaultTextures: [] userData: assetBundleName: assetBundleVariant: ================================================ FILE: Plugins/Simple360Render/Resources.meta ================================================ fileFormatVersion: 2 guid: 5d84eb59dd45e4c45952358448ed5279 folderAsset: yes timeCreated: 1498250425 licenseType: Free DefaultImporter: userData: assetBundleName: assetBundleVariant: ================================================ FILE: Plugins/Simple360Render/Simple360Render.Runtime.asmdef ================================================ { "name": "Simple360Render.Runtime" } ================================================ FILE: Plugins/Simple360Render/Simple360Render.Runtime.asmdef.meta ================================================ fileFormatVersion: 2 guid: 786f92cee518bdc448b345723f72322e AssemblyDefinitionImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: Plugins/Simple360Render.meta ================================================ fileFormatVersion: 2 guid: 161d6e8d9f8984c4a99d559eba29d660 folderAsset: yes timeCreated: 1498250360 licenseType: Free DefaultImporter: userData: assetBundleName: assetBundleVariant: ================================================ FILE: Plugins.meta ================================================ fileFormatVersion: 2 guid: 07bf4b39630a4274a8b76326493f09a6 folderAsset: yes DefaultImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: ================================================ FILE: package.json ================================================ { "name": "com.yasirkula.screenshotcapture", "displayName": "360\u00b0 Screenshot Capture", "version": "1.1.1", "documentationUrl": "https://github.com/yasirkula/Unity360ScreenshotCapture", "changelogUrl": "https://github.com/yasirkula/Unity360ScreenshotCapture/releases", "licensesUrl": "https://github.com/yasirkula/Unity360ScreenshotCapture/blob/master/LICENSE.txt", "description": "This plugin helps you capture 360\u00b0 screenshots in equirectangular format during gameplay." } ================================================ FILE: package.json.meta ================================================ fileFormatVersion: 2 guid: 6ba4c2542477a1c4f9d7150c62af4bf2 PackageManifestImporter: externalObjects: {} userData: assetBundleName: assetBundleVariant: