title: “VRChat 에서 뉴스 보기 (Unity + 최적화)” categories:

  • personalproject

    #VRChat 에서 뉴스 보기 (Unity + 최적화) : 네이버 블로그

VRChat 에서 뉴스 보기 (RSS+github.io)와 이어집니다

들어가며

전 포스팅에서는 RSS를 받아와 github.io에 자동으로 커밋하게끔 구축해두었다.

동아일보

매일경제

조선일보

HTML은 다음과 같다



테마0\_테마1\_테마2\_테마3\_
테마0\_기사0
기사1
기사2
기사3
....
^테마1\_기사0
기사1
기사2
....

Unity에서 필요한 기능은

  • 언론사, 테마에 따라 변경되는 UI
  • 한 면에 표시되는 기사의 수를 조절

를 목표로 했다.

Unity UI

버튼과 스크롤 뷰를 활용

우선적으로 생각해야 할 것은, 언론사의 수, 테마의 수만큼 버튼을 생성/삭제 하는것이다. 인스턴스화와 디스트로이로 생성/삭제가 가능하지만, 조금 귀찮더라도 셋 액티브로 해결하기로 했다.

Unity UI의 든든 국밥같은 친구, 스크롤뷰의 LayoutGroup과 ContentSizeFilter를 이용했다.

LayoutGroup은 자식 오브젝트를 자동으로 정렬해주는 기능이다. 수직 또는 수평으로 자식 오브젝트를 나열하고, 간격을 설정할 수 있다.

ContentSizeFilter는 UI 요소의 크기를 자동으로 조절하는 기능이다. 이 컴포넌트를 사용하면 부모 오브젝트의 크기에 맞게 UI 요소의 크기를 조절할 수 있다. 예를 들면, 부모 오브젝트의 크기가 변경되면 자식 UI 요소의 크기도 자동으로 조절된다.

버튼은 자동으로 왼쪽부터 채워지니, 모두 끈 후 필요한 수만큼 키면 되는것이다.

스크롤뷰의 콘텐츠에 버튼을 넣으면 레이캐스트가 겹쳐, 버튼의 클릭이 되지 않고 스크롤뷰로 가는 버그가 있는데, Event Trigger컴포넌트를 넣어 우선 순위를 올려주면 된다.

그 밖에는… 음..

VRChat한정으로 버튼과 상호작용을 하려면 레이아웃을 디폴트로, 캔버스에 VRC UI Shape 컴포넌트를 넣어줘야 한다.

그 밖에는 WASD키를 누르면 UI가 움직이니 Navigation을 꺼야 한다 정도?

필요한 것만 넣어둔 컨트롤 패널

컨트롤 패널만 있으면 심심하니 VR로 들고 다닐 수 있는 미니패널 하나

사실 구현보다 배경 텍스쳐 고르는게 더 힘들었다 ㅋㅋ

이과의 감성이란…

어짜피 뉴스 기능이 필요한 사람은 받은 다음에 꾸며서 쓰지 않을까….

기능에 집중하자

HTML 파싱 코드

파싱 라이브러리를 사용할 수 없어

이젠 HTML을 불러와서 기사를 잘라야 한다

생각해야 할 것은

  • 기능

    • 사용자가 HTML로드중일 때 조작을 못하게 예외처리
    • 사용자가 한 페이지의 기사 수를 조절 할 수 있게 만들기
  • 최적화

    • VR유저가 많은 많큼, 많은 양의 String을 처리하기에 가비지가 쌓이기 쉽다. Substring을 최소화하고 최대한 인덱스로 접근하자.
    • 한 페이지를 표시할때도 최대한 인덱스로 접근하자
    • 미리 뉴스를 가져오기보다는 필요할 때만 가져오게 하자
    • 중복되는 기능을 묶고 메소드로 만들자
using System;
using System.Linq;
using System.Text;
using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDK3.StringLoading;
using VRC.SDKBase;
using VRC.Udon;

public class KoreaNews : UdonSharpBehaviour
{
 private VRCUrl[] pressUrls = new VRCUrl[]
 {
 new VRCUrl("https://rage147-owo.github.io/VRChatKoreaNews/%EB%8F%99%EC%95%84%EC%9D%BC%EB%B3%B4.html"),
 new VRCUrl("https://rage147-owo.github.io/VRChatKoreaNews/%EB%A7%A4%EC%9D%BC%EA%B2%BD%EC%A0%9C.html"),
 new VRCUrl("https://rage147-owo.github.io/VRChatKoreaNews/%EC%A1%B0%EC%84%A0%EC%9D%BC%EB%B3%B4.html")
 };

 private string[] pressNames = new string[]
 {
 "동아일보",
 "매일경제",
 "조선일보"
 };
 [SerializeField] private Transform pressButtonParent;
 [SerializeField] private Transform themaButtonParent;
 [SerializeField] private Text newsText;
 [SerializeField] private Text PickUpText;
 [SerializeField] private int pageSize = 4;
 [SerializeField] private Text pageText;
 [SerializeField] private Text StatusText;

 private string \_news;
 private string \_articles;
 private string[] \_thema;
 private int \_currentPage = 1;
 private int \_currentThema = 0;
 private int \_maxPage;
 private int[] \_themaIndexes;
 private int[] \_pageIndexes;
 bool IsReady = false;

 private void Start()
 {
 Debug.Log("pressNameLength" + pressNames.Length);
 CreateButtons(pressButtonParent, pressNames.Length, pressNames);
 themaButtonParent.gameObject.SetActive(false);
 SetStatusText("IsReady");
 }

 private void SetStatusText(string \_\_text)
 {
 StatusText.text = \_\_text;
 }

 public override void OnStringLoadSuccess(IVRCStringDownload result)
 {
 \_news = result.Result.Substring(49, result.Result.Length - 16 - 49);
 int firstNewlineIndex = \_news.IndexOf('\n');
 \_thema = \_news.Substring(0, firstNewlineIndex).TrimEnd('\_').Split('\_');
 \_news = \_news.Remove(0, firstNewlineIndex + 1);
 CreateButtons(themaButtonParent, \_thema.Length, \_thema);
 SetThemaIndexes();
 SetStatusText("Last Update: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
 themaButtonParent.gameObject.SetActive(true);
 }

 private void CreateButtons(Transform parent, int buttonCount, string[] buttonTexts)
 {
 Debug.Log("CreateButtons: " + buttonCount + " called");

 Text[] textComponents = parent.GetComponentsInChildren();

 for (int i = 0; i < textComponents.Length; i++)
 {
 if (i < buttonCount)
 {
 textComponents[i].transform.parent.gameObject.SetActive(true);
 textComponents[i].text = buttonTexts[i];
 }
 else
 {
 textComponents[i].transform.parent.gameObject.SetActive(false);
 }
 }
 }

 private void ShowPage(int page, int themeIndex)
 {

 pageText.text = page + "/" + \_maxPage;
 if (page == 1)
 {
 newsText.text = \_articles.Substring(0, \_pageIndexes[1]);
 PickUpText.text = newsText.text;
 }
 else if (page == \_maxPage)
 {
 Debug.Log("LastPage");
 newsText.text = \_articles.Substring(\_pageIndexes[\_pageIndexes.Length-1], \_articles.Length - \_pageIndexes[\_pageIndexes.Length - 1]);
 PickUpText.text = newsText.text;
 }
 else
 {
 newsText.text = \_articles.Substring(\_pageIndexes[page - 1], \_pageIndexes[page] - \_pageIndexes[page - 1]);
 PickUpText.text = newsText.text;
 }
 }

 private string GetArticlesForTheme(int themeIndex)
 {
 if (themeIndex == 0)
 {
 return \_news.Substring(0, \_themaIndexes[0]);
 }

 if (themeIndex == \_thema.Length - 1)
 {
 return \_news.Substring(\_themaIndexes[themeIndex - 1], \_news.Length - \_themaIndexes[themeIndex - 1]);
 }

 return \_news.Substring(\_themaIndexes[themeIndex - 1],
 \_themaIndexes[themeIndex] - \_themaIndexes[themeIndex - 1]);
 }
 
 public void NextPage()
 {
 if(IsReady== false)
 return;
 Debug.Log("NextPage");
 if (\_currentPage < \_maxPage)
 {
 \_currentPage++;
 ShowPage(\_currentPage, \_currentThema);
 }
 }

 public void PrePage()
 {
 if(IsReady== false)
 return;
 Debug.Log("PrePage");
 if (\_currentPage > 1)
 {
 \_currentPage--;
 ShowPage(\_currentPage, \_currentThema);
 }
 }
private void SetPageIndexes(int themeIndex)
 {
 \_articles = GetArticlesForTheme(themeIndex);
 int count = 0;
 for (int i = 0; i < \_articles.Length; i++)
 {
 if (\_articles[i] == '\_')
 {
 Debug.Log(\_articles);
 count++;
 }
 }
 \_maxPage = count / pageSize;

 if (count < pageSize\*2)
 {
 \_maxPage = 2;
 }
 \_pageIndexes = new int[\_maxPage];

 int lastIndex = 0;
 int temp = 0;
 
 
 for (int i = 0; i < count; i++)
 {
 if(\_pageIndexes.Length == temp)
 {
 Debug.Log(temp);
 for(int j=0;j<\_pageIndexes.Length;j++)
 {
 Debug.Log("PageIndexes: " + j + " " + \_pageIndexes[j]);
 }
 break;
 }
 int currentIndex = \_articles.IndexOf('\_', lastIndex);
 lastIndex = currentIndex + 1;

 if (i % pageSize == 0)
 {
 \_pageIndexes[temp] = currentIndex;
 temp++;
 }
 }
 IsReady= true;
 }

 private void SetThemaIndexes()
 {
 int themeCount = \_thema.Length;
 \_themaIndexes = new int[themeCount - 1];

 int lastIndex = 0;

 for (int i = 0; i < themeCount - 1; i++)
 {
 int currentIndex = \_news.IndexOf('^', lastIndex);
 \_themaIndexes[i] = currentIndex;
 lastIndex = currentIndex + 1;
 }
 }

 public void ThemaButtonClicked(int themaIndex)
 {
 Debug.Log("ThemaButtonCkick : " + themaIndex + "");
 \_currentThema = themaIndex;
 SetPageIndexes(themaIndex);
 \_currentPage = 1;
 ShowPage(1, themaIndex);
 }

 public void Press(int pressIndex)
 {
 Debug.Log("PressButton : " + pressIndex + "");

 VRC.SDK3.StringLoading.VRCStringDownloader.LoadUrl(pressUrls[pressIndex], GetComponent());
 themaButtonParent.gameObject.SetActive(false);
 SetStatusText("Loading...");
 }

 public void Thema0()
 {
 ThemaButtonClicked(0);
 }

 public void Thema1() => ThemaButtonClicked(1);
 public void Thema2() => ThemaButtonClicked(2);
 public void Thema3() => ThemaButtonClicked(3);
 public void Thema4() => ThemaButtonClicked(4);
 public void Thema5() => ThemaButtonClicked(5);
 public void Thema6() => ThemaButtonClicked(6);
 public void Thema7() => ThemaButtonClicked(7);
 public void Thema8() => ThemaButtonClicked(8);
 public void Thema9() => ThemaButtonClicked(9);
 public void Thema10() => ThemaButtonClicked(10);
 public void Thema11() => ThemaButtonClicked(11);
 public void Thema12() => ThemaButtonClicked(12);
 public void Thema13() => ThemaButtonClicked(13);
 public void Thema14() => ThemaButtonClicked(14);
 public void Thema15() => ThemaButtonClicked(15);
 public void Thema16() => ThemaButtonClicked(16);
 public void Thema17() => ThemaButtonClicked(17);
 public void Thema18() => ThemaButtonClicked(18);
 public void Thema19() => ThemaButtonClicked(19);
 public void Press0() => Press(0);
 public void Press1() => Press(1);
 public void Press2() => Press(2);
 public void Press3() => Press(3);
 public void Press4() => Press(4);
 public void Press5() => Press(5);
 public void Press6() => Press(6);
 public void Press7() => Press(7);
 public void Press8() => Press(8);
 public void Press9() => Press(9);
 public void Press10() => Press(10);
 public void Press11() => Press(11);
 public void Press12() => Press(12);
 public void Press13() => Press(13);
 public void Press14() => Press(14);
 public void Press15() => Press(15);
 public void Press16() => Press(16);
 public void Press17() => Press(17);
 public void Press18() => Press(18);

 public void Press19()
 {
 Press(19);
 }
}

아래는 메인인 KoreaNews코드이다.

우선 Start에서 언론사 수만큼 버튼을 켠다.

언론사 버튼이 눌려지면 맨 아래 public void Press(int pressIndex)에서 언론사의 HTML을 가져온다.

가져온 HTML은 OnStringLoadSuccess되어 Html태그를 제거하고 테마를 구분해 테마의 수와 종류를 버튼에 띄운다.

테마 버튼을 선택하면 ThemaButtonClicked로 가서 SetPageIndexes를 돌리고 ShowPage한다.

PageIndexes는 한 테마에서 각 페이지마다 보여줄 범위를 가진다.

SetPageIndexes에서는 _(언더바)마다 나눠진 기사를 pagesize만큼 나눈 후, 각 페이지에서 어디까지 표시할지 인덱스를 저장한다.

사실 코드만 길 뿐이지 들어가는 로직은 없다.

생각치도 못한 숫자세기 문제와(마지막 인덱스가 넘치는 경우) 앞으로 멀티 플레이어를 위한 싱크를 제외하고는 코드만 길 뿐이다.

맨 아래의 Thema(), Press()는 테마, 언론사의 버튼이다.

버튼은 유동적으로 수가 바뀌므로 일단 최대값 20으로 맞춰놓았다.

아래는 버튼의 코드이다.

public void ButtonClicked()
 {
 transform.parent.parent.parent.parent.GetComponent()
 .SendCustomEvent(name.Substring(0,5)+transform.GetSiblingIndex());
 
 }

버튼의 상위 UdonBehavior를 찾은 뒤 자신의 이름 앞 5글자와 본인의 하이라키 순서를 이벤트 이름으로 보낸다.

transform.parent.parent.parent.parent 는 추하긴 하지만… GetComponentInParent보다는 낫지 않을까? 싶다.

아. 몰랐었는데 GetComponentInParent는 본인부터 찾기 때문에 transform.parent를 하고 GetComponentInParent을 사용해야 한다.

나중에 작동이 안된다면 필시 여기 부분 일 것이다.

시연&마무리하며

잘 된다. 야호

아래는 간단한 시연

https://youtu.be/Ku52Hjhq1mA

글자의 크기가 가독성이 떨어지긴 하지만… 그건 이 뉴스 패널을 사용할 사람의 몫 아닐…까? ㅋㅋ

앞으로 시간이 난다면 해야 할 일은

SubString(최적화): SubString은 가비지를 생성하므로 StringBuilder를 사용해야 한다. 그러나 VRChat에서는 StringBuilder가 사용불가능하다. 이를 해결하기 위해서는 Remove를 사용하거나 인덱스를 이용해 각 기사에 접근하는 방법을 이용해야 한다.

Sync: 다른 플레이어와의 Sync를 위해서는 현재 플레이어가 보고 있는 언론사,테마,페이지를 가져와야 한다.

HTML을 가져온 후 5초간 로드가 불가능하므로, 만약 다른 플레이어가 로드를 마친 후 페이지조작을 하면 String에서 오류가 날 수 있다.

이를 위한 예외처리를 해야 한다. VRChat에서 try catch가 불가능한것은 덤…

가볍게 시작한 일인데 하루가 후딱 지나가버렸다.

최근 졸업작품만 하다가 내가 하고싶은 것을 하니 조금 더 편안하긴 하다.

다음에 시간만 허락한다면 실시간 증권, 날씨, 블로그도 갱신하는 걸 만들고 싶다.

업데이트: