Posts 유니티 - 스크린샷 찍고 저장하기(Windows, Android)
Post
Cancel

유니티 - 스크린샷 찍고 저장하기(Windows, Android)

1. Unity Editor


저장할 경로

  • $"{Application.dataPath}/ScreenShots/"

실제 경로

  • "[프로젝트 디렉토리]/Assets/ScreenShots/"


2. Standalone App


저장할 경로

  • $"{Application.dataPath}/ScreenShots/"

실제 경로

  • "[실행파일명]/[실행파일명_Data]/ScreenShots/"


3. Android


저장할 경로

  • $"{Application.persistentDataPath}/ScreenShots/"

실제 경로

  • "/storage/emulated/0/Android/data/[패키지명]/files/ScreenShots/"


권한 요청하기

Assets/Plugins/Android/AndroidManifest.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?xml version="1.0" encoding="utf-8"?>
<!-- GENERATED BY UNITY. REMOVE THIS COMMENT TO PREVENT OVERWRITING WHEN EXPORTING AGAIN-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.unity3d.player" xmlns:tools="http://schemas.android.com/tools">
  <application>
    <activity android:name="com.unity3d.player.UnityPlayerActivity" android:theme="@style/UnityThemeSelector" android:screenOrientation="fullSensor" android:launchMode="singleTask" android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|uiMode|screenSize|smallestScreenSize|fontScale|layoutDirection|density" android:resizeableActivity="false" android:hardwareAccelerated="false">
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
      <meta-data android:name="unityplayer.UnityActivity" android:value="true" />
      <meta-data android:name="android.notch_support" android:value="true" />
    </activity>
    <meta-data android:name="unity.splash-mode" android:value="0" />
    <meta-data android:name="unity.splash-enable" android:value="True" />
    <meta-data android:name="unity.allow-resizable-window" android:value="False" />
    <meta-data android:name="notch.config" android:value="portrait|landscape" />
    <meta-data android:name="unity.build-id" android:value="a1c3f18e-230d-4c7c-942d-593114624e7c" />
  </application>
  <uses-feature android:glEsVersion="0x00030000" />
  <uses-feature android:name="android.hardware.vulkan.version" android:required="false" />
  <uses-feature android:name="android.hardware.touchscreen" android:required="false" />
  <uses-feature android:name="android.hardware.touchscreen.multitouch" android:required="false" />
  <uses-feature android:name="android.hardware.touchscreen.multitouch.distinct" android:required="false" />

  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
</manifest>


4. 스크린샷 찍기


[1] 스크린샷 캡쳐 기능 구현

우선, 화면 크기의 텍스쳐를 생성한다.

1
Texture2D screenTex = new Texture2D(Screen.width, Screen.height, TextureFormat.RGB24, false);

그리고 캡쳐할 영역을 지정한 뒤, 현재 화면 픽셀들을 .ReadPixels() 메소드를 통해 텍스쳐 픽셀에 저장한다.

1
2
Rect area = new Rect(0f, 0f, Screen.width, Screen.height);
screenTex.ReadPixels(area, 0, 0);

저장할 대상 경로의 폴더가 존재하는지 확인하고, 없으면 생성한다.

그리고 텍스쳐를 PNG 포맷의 byte[]로 변환한 뒤 파일 경로에 작성한다.

1
2
3
4
5
6
7
8
// 폴더가 존재하지 않으면 새로 생성
if (Directory.Exists(FolderPath) == false)
{
    Directory.CreateDirectory(FolderPath);
}

// 스크린샷 저장
File.WriteAllBytes(TotalPath, screenTex.EncodeToPNG());

마지막으로, 텍스쳐를 메모리에서 해제한다.

1
Destroy(screenTex);


[2] UI 포함하여 화면 전체 캡쳐하기

위의 기능을 그냥 실행하면 예외가 발생한다.

1
ReadPixels was called to read pixels from system frame buffer, while not inside drawing frame.

ReadPixels() 메소드는 현재 프레임 버퍼로부터 픽셀들을 읽어오기 때문에,

프레임 버퍼가 완전히 초기화된 이후에 호출하라는 뜻이다.

다시 말해, 해당 프레임의 렌더링이 모두 종료되고 호출해야 된다는 의미.

방법은 간단하다.

코루틴에 넣고, EndOfFrame을 기다린 뒤 처리하면 된다.

1
2
3
4
5
6
private IEnumerator TakeScreenShotRoutine()
{
    yield return new WaitForEndOfFrame();
    
    // Screen Capture Code Here //
}


[3] UI 미포함, 카메라가 렌더링하는 부분만 캡쳐하기

[2]의 방법은 화면 전체를 캡쳐하며, UI도 포함한다.

UI를 포함하지 않고, 카메라가 렌더링하는 모습만 순수하게 담으려면

우선 스크린 캡쳐 스크립트를 카메라 컴포넌트가 위치한 게임오브젝트에 넣어야 한다.

그리고 멤버 변수를 하나 만든다.

1
private _willTakeScreenShot = false;

그리고 OnPostRender() 메소드를 작성한다.

1
2
3
4
5
6
7
8
9
private void OnPostRender()
{
    if (_willTakeScreenShot)
    {
        _willTakeScreenShot = false;
        
        // Screen Capture Code Here //
    }
}

OnPostRender() 메소드는 카메라와 함께 있는 컴포넌트에 작성했을 때 동작하며,

프레임마다 해당 카메라의 렌더링이 끝난 후에 호출된다.

따라서 여기에 스크린샷 코드를 넣으면 카메라가 렌더링을 마친 모습만 저장하게 된다.


[4] PNG 파일을 읽어와서 화면에 출력하기

우선, 파일을 읽어 와야 하는데

해당 디렉토리와 파일이 존재하는지 각각 검사해준다.

1
2
3
4
5
6
7
8
9
10
if (Directory.Exists(folderPath) == false)
{
    Debug.LogWarning($"{folderPath} 폴더가 존재하지 않습니다.");
    return;
}
if (File.Exists(totalPath) == false)
{
    Debug.LogWarning($"{totalPath} 파일이 존재하지 않습니다.");
    return;
}

그리고 파일의 존재 여부가 확인되면 파일을 읽어온다.

1
byte[] texBuffer = File.ReadAllBytes(totalPath);

빈 텍스쳐를 생성하고, 버퍼로부터 데이터를 읽어온다.

1
2
3
4
5
6
7
8
Texture2D imageTexture = new Texture2D(1, 1, TextureFormat.RGB24, false);
imageTexture.LoadImage(texBuffer);

// NOTE
// LoadImage()로부터 텍스쳐 크기가 결정되므로 앞의 두 개의 파라미터는 사실 딱히 의미가 없다.
// 세 번째 파라미터도 마찬가지지만 default로 넣으면 0 값이 들어가는데, TextureFormat에 0이 없으므로 에러가 난다.
// 네 번째 파라미터를 true로 바꾸면 밉맵 체인을 형성하므로, false로 해준다.
_imageTexture = new Texture2D(0, 0, TextureFormat.RGB24, false);

이제 두 가지 방법이 있다.

첫 번째는 RawImage 컴포넌트로 텍스쳐를 곧바로 화면에 보여주는 것이고,

두 번째는 Sprite를 생성한 뒤 Image 컴포넌트의 sprite 프로퍼티에 넣어주는 것이다.

1
2
Rect rect = new Rect(0, 0, imageTexture.width, imageTexture.height);
Sprite sprite = Sprite.Create(imageTexture, rect, Vector2.one * 0.5f);


5. 안드로이드 - 갤러리에 저장하기


쓰기 권한을 얻어서 스크린샷을 경로에 저장하는 것은 문제가 없지만

이렇게 기기 내에 저장된 이미지 파일은 갤러리에 바로 표시되지 않는다.

여기에 추가적인 조치가 더 필요하다.


기본 저장 경로 변경

Application.persistentDataPath 경로는

해당 애플리케이션만의 고유 데이터 경로로서, 앱이 제거되면 함께 제거된다는 특징이 있다.

실제로 /storage/emulated/0/Android/data/<packagename>/files 이런 경로에 저장된다.

그런데 여기에 저장하면 파일을 읽고, 쓰고, 덮어쓸 수도 있으나 갤러리에는 업데이트 되지 않는다는 단점이 있다.


따라서 기본 경로를 /storage/emulated/0/DCIM/{Application.productName}으로 변경한다.

갤러리 사진들이 저장되는 경로의 하위에 위와 같이 경로를 설정하여 저장한다.

파일을 읽고 쓸 수는 있으나, 이미 존재하는 파일에 덮어쓸 수는 없다.

따라서 스크린샷을 저장할 때마다 다른 이름으로 저장해야 한다.

그리고 스크린샷을 저장한 후, 해당 경로를 갱신해주는 코드를 추가하면 된다.


갤러리 갱신 소스코드 추가

1
2
3
4
5
6
7
8
9
10
[System.Diagnostics.Conditional("UNITY_ANDROID")]
private void RefreshAndroidGallery(string imageFilePath)
{
    AndroidJavaClass classPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
    AndroidJavaObject objActivity = classPlayer.GetStatic<AndroidJavaObject>("currentActivity");
    AndroidJavaClass classUri = new AndroidJavaClass("android.net.Uri");
    AndroidJavaObject objIntent = new AndroidJavaObject("android.content.Intent", new object[2]
    { "android.intent.action.MEDIA_MOUNTED", classUri.CallStatic<AndroidJavaObject>("parse", "file://" + imageFilePath) });
    objActivity.Call("sendBroadcast", objIntent);
}

다른 권한은 추가로 필요 없고,

안드로이드 스크린샷 저장 후 위의 메소드를 한 번 호출해주면 갤러리가 갱신된다.

매개변수에는 저장된 이미지의 전체 경로를 그대로 넣어주면 된다.


전체 소스 코드


Test_ScreenShot.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System;
using System.IO;

#if UNITY_ANDROID
using UnityEngine.Android;
#endif

public class Test_ScreenShot : MonoBehaviour
{
    /***********************************************************************
    *                               Public Fields
    ***********************************************************************/
    #region .
    public Button screenShotButton;          // 전체 화면 캡쳐
    public Button screenShotWithoutUIButton; // UI 제외 화면 캡쳐
    public Button readAndShowButton; // 저장된 경로에서 스크린샷 파일 읽어와서 이미지에 띄우기
    public Image imageToShow;        // 띄울 이미지 컴포넌트

    public ScreenShotFlash flash;

    public string folderName = "ScreenShots";
    public string fileName = "MyScreenShot";
    public string extName = "png";

    private bool _willTakeScreenShot = false;
    #endregion
    /***********************************************************************
    *                               Fields & Properties
    ***********************************************************************/
    #region .
    private Texture2D _imageTexture; // imageToShow의 소스 텍스쳐

    private string RootPath
    {
        get
        {
#if UNITY_EDITOR || UNITY_STANDALONE
            return Application.dataPath;
#elif UNITY_ANDROID
            return $"/storage/emulated/0/DCIM/{Application.productName}/";
            //return Application.persistentDataPath;
#endif
        }
    }
    private string FolderPath => $"{RootPath}/{folderName}";
    private string TotalPath => $"{FolderPath}/{fileName}_{DateTime.Now.ToString("MMdd_HHmmss")}.{extName}";

    private string lastSavedPath;

    #endregion

    /***********************************************************************
    *                               Unity Events
    ***********************************************************************/
    #region .
    private void Awake()
    {
        screenShotButton.onClick.AddListener(TakeScreenShotFull);
        screenShotWithoutUIButton.onClick.AddListener(TakeScreenShotWithoutUI);
        readAndShowButton.onClick.AddListener(ReadScreenShotAndShow);
    }
    #endregion
    /***********************************************************************
    *                               Button Event Handlers
    ***********************************************************************/
    #region .
    /// <summary> UI 포함 전체 화면 캡쳐 </summary>
    private void TakeScreenShotFull()
    {
#if UNITY_ANDROID
        CheckAndroidPermissionAndDo(Permission.ExternalStorageWrite, () => StartCoroutine(TakeScreenShotRoutine()));
#else
        StartCoroutine(TakeScreenShotRoutine());
#endif
    }

    /// <summary> UI 미포함, 현재 카메라가 렌더링하는 화면만 캡쳐 </summary>
    private void TakeScreenShotWithoutUI()
    {
#if UNITY_ANDROID
        CheckAndroidPermissionAndDo(Permission.ExternalStorageWrite, () => _willTakeScreenShot = true);
#else
        _willTakeScreenShot = true;
#endif
    }

    private void ReadScreenShotAndShow()
    {
#if UNITY_ANDROID
        CheckAndroidPermissionAndDo(Permission.ExternalStorageRead, () => ReadScreenShotFileAndShow(imageToShow));
#else
        ReadScreenShotFileAndShow(imageToShow);
#endif
    }
    #endregion
    /***********************************************************************
    *                               Methods
    ***********************************************************************/
    #region .

    // UI 포함하여 현재 화면에 보이는 모든 것 캡쳐
    private IEnumerator TakeScreenShotRoutine()
    {
        yield return new WaitForEndOfFrame();
        CaptureScreenAndSave();
    }

    // UI 제외하고 현재 카메라가 렌더링하는 모습 캡쳐
    private void OnPostRender()
    {
        if (_willTakeScreenShot)
        {
            _willTakeScreenShot = false;
            CaptureScreenAndSave();
        }
    }

#if UNITY_ANDROID
    /// <summary> 안드로이드 - 권한 확인하고, 승인시 동작 수행하기 </summary>
    private void CheckAndroidPermissionAndDo(string permission, Action actionIfPermissionGranted)
    {
        // 안드로이드 : 저장소 권한 확인하고 요청하기
        if (Permission.HasUserAuthorizedPermission(permission) == false)
        {
            PermissionCallbacks pCallbacks = new PermissionCallbacks();
            pCallbacks.PermissionGranted += str => Debug.Log($"{str} 승인");
            pCallbacks.PermissionGranted += str => AndroidToast.I.ShowToastMessage($"{str} 권한을 승인하셨습니다.");
            pCallbacks.PermissionGranted += _ => actionIfPermissionGranted(); // 승인 시 기능 실행

            pCallbacks.PermissionDenied += str => Debug.Log($"{str} 거절");
            pCallbacks.PermissionDenied += str => AndroidToast.I.ShowToastMessage($"{str} 권한을 거절하셨습니다.");

            pCallbacks.PermissionDeniedAndDontAskAgain += str => Debug.Log($"{str} 거절 및 다시는 보기 싫음");
            pCallbacks.PermissionDeniedAndDontAskAgain += str => AndroidToast.I.ShowToastMessage($"{str} 권한을 격하게 거절하셨습니다.");

            Permission.RequestUserPermission(permission, pCallbacks);
        }
        else
        {
            actionIfPermissionGranted(); // 바로 기능 실행
        }
    }
#endif

    /// <summary> 스크린샷을 찍고 경로에 저장하기 </summary>
    private void CaptureScreenAndSave()
    {
        string totalPath = TotalPath; // 프로퍼티 참조 시 시간에 따라 이름이 결정되므로 캐싱

        Texture2D screenTex = new Texture2D(Screen.width, Screen.height, TextureFormat.RGB24, false);
        Rect area = new Rect(0f, 0f, Screen.width, Screen.height);

        // 현재 스크린으로부터 지정 영역의 픽셀들을 텍스쳐에 저장
        screenTex.ReadPixels(area, 0, 0);

        bool succeeded = true;
        try
        {
            // 폴더가 존재하지 않으면 새로 생성
            if (Directory.Exists(FolderPath) == false)
            {
                Directory.CreateDirectory(FolderPath);
            }

            // 스크린샷 저장
            File.WriteAllBytes(totalPath, screenTex.EncodeToPNG());
        }
        catch (Exception e)
        {
            succeeded = false;
            Debug.LogWarning($"Screen Shot Save Failed : {totalPath}");
            Debug.LogWarning(e);
        }

        // 마무리 작업
        Destroy(screenTex);

        if (succeeded)
        {
            Debug.Log($"Screen Shot Saved : {totalPath}");
            flash.Show(); // 화면 번쩍
            lastSavedPath = totalPath; // 최근 경로에 저장
        }

        // 갤러리 갱신
        RefreshAndroidGallery(totalPath);
    }

    [System.Diagnostics.Conditional("UNITY_ANDROID")]
    private void RefreshAndroidGallery(string imageFilePath)
    {
#if !UNITY_EDITOR
        AndroidJavaClass classPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
        AndroidJavaObject objActivity = classPlayer.GetStatic<AndroidJavaObject>("currentActivity");
        AndroidJavaClass classUri = new AndroidJavaClass("android.net.Uri");
        AndroidJavaObject objIntent = new AndroidJavaObject("android.content.Intent", new object[2]
        { "android.intent.action.MEDIA_SCANNER_SCAN_FILE", classUri.CallStatic<AndroidJavaObject>("parse", "file://" + imageFilePath) });
        objActivity.Call("sendBroadcast", objIntent);
#endif
    }

    // 가장 최근에 저장된 이미지 보여주기
    /// <summary> 경로로부터 저장된 스크린샷 파일을 읽어서 이미지에 보여주기 </summary>
    private void ReadScreenShotFileAndShow(Image destination)
    {
        string folderPath = FolderPath;
        string totalPath = lastSavedPath;

        if (Directory.Exists(folderPath) == false)
        {
            Debug.LogWarning($"{folderPath} 폴더가 존재하지 않습니다.");
            return;
        }
        if (File.Exists(totalPath) == false)
        {
            Debug.LogWarning($"{totalPath} 파일이 존재하지 않습니다.");
            return;
        }

        // 기존의 텍스쳐 소스 제거
        if (_imageTexture != null)
            Destroy(_imageTexture);
        if (destination.sprite != null)
        {
            Destroy(destination.sprite);
            destination.sprite = null;
        }

        // 저장된 스크린샷 파일 경로로부터 읽어오기
        try
        {
            byte[] texBuffer = File.ReadAllBytes(totalPath);

            _imageTexture = new Texture2D(1, 1, TextureFormat.RGB24, false);
            _imageTexture.LoadImage(texBuffer);
        }
        catch (Exception e)
        {
            Debug.LogWarning($"스크린샷 파일을 읽는 데 실패하였습니다.");
            Debug.LogWarning(e);
            return;
        }

        // 이미지 스프라이트에 적용
        Rect rect = new Rect(0, 0, _imageTexture.width, _imageTexture.height);
        Sprite sprite = Sprite.Create(_imageTexture, rect, Vector2.one * 0.5f);
        destination.sprite = sprite;
    }
    #endregion
}


예제 씬 다운로드



References


This post is licensed under CC BY 4.0 by the author.