WebGL에서 Unity Gaming Service를 연동하던 중, 익명 사용자로 로그인한 상태에서 브라우저 탭을 새 탭으로 변경하거나 최소화한 뒤 약 1분 30초 ~ 2분이 지나면 다음과 같은 에러가 발생했다.

 

<코드> 

    // Start is called before the first frame update
    async void Start()
    {
        try
        {
            await UnityServices.InitializeAsync();
            Debug.Log($"Unity Services의 초기화를 성공하였습니다., UnityServices.State: [{UnityServices.State}]");

            AuthenticationService.Instance.SwitchProfile(UserName);
            await AuthenticationService.Instance.SignInAnonymouslyAsync();
            Debug.Log($"Unity Services의 익명 사용자 계정으로 로그인을 성공하였습니다. PlayerName: {UserName}");
        }
        catch (ArgumentException e)
        {
            Console.WriteLine($"InitializeAsync Processing failed: {e.Message}");
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }

 

<에러 내용>

 

에러 내용은 framework.js의 5079번째 줄에서 readState를 읽을 수 없다는 것이었다.

 

해당 내용을 확인하기 위해 Unity의 Build Settings에서 Development Build를 체크한 후 빌드했습니다. 이후, framework.js 파일에 콘솔 로그를 추가하고 브라우저의 개발자 모드에서 로그를 확인했다.

 

<Development Build 체크>

 

<콘솔 로그 작성>

framework.js 파일
에러난 부분에 위에 콘솔 로그 작성

 

<에러 내용 확인>

 

 

에러 내용에 보듯이 "instance.ws: undefined" 라고 적혀있다. 그 윗줄에 로그를 보면 instance에 ws: Websoket이 있지만 해당 에러가 발생한 시점에는 존재하지 않아서 undefined 로그를 출력하는걸 볼수 있었다. 

 

자 그럼 이제 _WebSocketSend의 함수의 코드를 보도록 하자.

여기서 문제는 5076번째 줄의 조건문은 undefined를 체크할 수가 없다. 찾아보니 자바스크립트는 null === undefined 는 false로 판정이 된다.

참고 블로그: 

https://tmdrnr96.tistory.com/27

 

[Javascript] ==와===의 차이

Javascript를 사용 중에 값을 비교해야 할 때가 있는데, 이때 비교연산자인 ==연산자와 ===연산자를 사용한다. 두 연산자 모두 비교한 피연산자 값이 일치하면 ture값을 반환하고 비교한 피연산자 값

tmdrnr96.tistory.com

 

그래서 다음과 같이 조건문을 instance.ws === null을 !instance.ws로 변경했다. 아 물론 instance == null을 사용해도 된다.

<코드>

 

<결과 확인>

 

Websocket is not connected 에러만 뜰뿐, 프로그램은 원할하게 작동된다. 

 

이제 여기서 Development Buld로 빌드하는 것이 아니라 일반 빌드를 해야지 최종 배포를 할 수 있을것이다.

 

찾다보니 나랑 비슷한 문제가 있었던 글을 발견했고 해결방법도 적혀있었다.

 

https://discussions.unity.com/t/strange-exception-in-webgl-build-when-tab-is-inactive/946640

 

대충 위의 글을 framework.js 압축을 해제해서 수정한 후 다시 압축하라는 것이였다.

 

그래서 나는 위의 글대로 압축을 해제하고 _WebSocketSend 함수를 찾아 if (instance.ws === null) 를 if(!instance.ws) 로 변경하고 7-zip을 사용하여 .gz 확장자로 다시 압축을 진행하고 실행하니까 정상적으로 작동하는걸 볼 수 있었다.

 

추가적으로 유니티에서 답변 받은 내용이 있어서 적는다.

압축을 위의 글 처럼 압축을 해제해서 변경한 뒤에 다시 압축할 필요 없이 Plugin의 jslib로 변경하는 방법이 있었다.

Packages폴더에서 Wire/Plugins/WebSocket.jslib 파일이 있다. 여기서 WebSocketSend를 찾아 변경하면은 정상적으로 작동되는걸 확인할 수 있다.

<파일 위치>

<코드 변경>

 

 

파일도 첨부 해놓는다.

WebSocket.jslib
0.01MB

 

 

이 버그는 언제 고쳐줄지는 모르겠지만 고치기 전까지는 이 방법을 써야겠다.

해당 문제가 나오는 버전

※ Unity Editor Version: 2022.3.45f1

※ Vivox Version: 16.5.4

 

유니티의 Webgl에서 Vivox를 사용하여 음성 채팅을 연동할 일이 생겼었다. 찾아보니 WebGL 지원은 제한적으로 16.5.0 버전부터 SDK가 지원이 되었다.

https://docs.unity.com/ugs/ko-kr/manual/vivox-unity/manual/Unity/developer-guide/vivox-webgl

 

Unity SDK WebGL 지원

English日本語 (日本)한국어(대한민국)中文(中国) English日本語 (日本)한국어(대한민국)中文(中国) Unity SDK WebGL 지원#Vivox Unity WebGL SDK는 16.5.0 버전부터 사용할 수 있습니다.이 SDK는 제한된 수준

docs.unity.com

 

그러던중 샘플 자료를 토대로 작업을 하다가 WebGL에서 그룹 채널을 참가하지 못하는 문제가 있었다.

 

문제의 코드는 다음과 같다.

    private string _playerName = string.Empty;
    private const string _chanelName = "TestChannel";

    // Start is called before the first frame update
    async void Start()
    {
        await InitializeAsync();
        await LoginToVivoxAsync();
        await JoinGroupChannelAsync();
        Debug.Log("채널 참석 완료!");
    }
    private void OnDestroy()
    {
        VivoxService.Instance.LoggedIn -= OnUserLoggedIn;
        VivoxService.Instance.LoggedOut -= OnUserLoggedOut;

        VivoxService.Instance.ChannelJoined -= OnChannelJoined;
        VivoxService.Instance.ChannelLeft -= OnChannelLeft;
    }
	// 서비스 초기화
    private async Task InitializeAsync()
    {
        try
        {
            await UnityServices.InitializeAsync();
            Debug.Log($"Unity Services의 초기화를 성공하였습니다., UnityServices.State: [{UnityServices.State}]");

            await VivoxService.Instance.InitializeAsync();
            Debug.Log($"Vivox Services의 초기화를 성공하였습니다.");

            _playerName = PlayerNameLookUpTable();
            AuthenticationService.Instance.SwitchProfile(_playerName);
            await AuthenticationService.Instance.SignInAnonymouslyAsync();
            Debug.Log($"Unity Services의 익명 사용자 계정으로 로그인을 성공하였습니다. PlayerName: {_playerName}");

            VivoxService.Instance.LoggedIn += OnUserLoggedIn;
            VivoxService.Instance.LoggedOut += OnUserLoggedOut;

            VivoxService.Instance.ChannelJoined += OnChannelJoined;
            VivoxService.Instance.ChannelLeft += OnChannelLeft;
        }
        catch (ArgumentException e)
        {
            Console.WriteLine($"InitializeAsync Processing failed: {e.Message}");
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }
    // Vivox 로그인
    private async Task LoginToVivoxAsync()
    {
        try
        {
            LoginOptions options = new LoginOptions()
            {
                DisplayName = _playerName,
                ParticipantUpdateFrequency = ParticipantPropertyUpdateFrequency.FivePerSecond
            };
            await VivoxService.Instance.LoginAsync(options);
            Debug.Log($"Vivox의 로그인을 하였습니다.");
        }
        catch (ArgumentException e)
        {
            Console.WriteLine($"LoginToVivoxAsync Processing failed: {e.Message}");
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }
    // 그룹 채널 참가
    private async Task JoinGroupChannelAsync()
    {
        try
        {
            await VivoxService.Instance.JoinGroupChannelAsync(_chanelName, ChatCapability.AudioOnly);
            Debug.Log($"[{_chanelName}]의 그룹 채널을 참가하였습니다.");
        }
        catch (ArgumentException e)
        {
            Console.WriteLine($"JoinEchoChannelAsync Processing failed: {e.Message}");
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }
    
    private void OnUserLoggedIn()
    {
        Debug.Log("Vivox에 유저가 로그인되었습니다.");
    }
    private void OnUserLoggedOut()
    {
        Debug.Log("Vivox의 유저가 로그아웃되었습니다.");
    }

    private void OnChannelJoined(string channel)
    {
        Debug.Log($"[{channel}] 채널에 참석하였습니다.");
    }
    private void OnChannelLeft(string channel)
    {
        Debug.Log($"[{channel}] 채널을 나갔습니다.");
    }

    private string PlayerNameLookUpTable()
    {
        string[] table = new string[] { "Editor", "Window", "Android", "WebGL" };
        string result = string.Empty;
        switch (Application.platform)
        {
            case RuntimePlatform.WindowsEditor:
                {
                    result = table[0];
                    break;
                }
            case RuntimePlatform.WindowsPlayer:
                {
                    result = table[1];
                    break;
                }
            case RuntimePlatform.Android:
                {
                    result = table[2];
                    break;
                }
            case RuntimePlatform.WebGLPlayer:
                {
                    result = table[3];
                    break;
                }
        }

        return result;
    }

 

이유는 Start 함수에서 서비스 초기화, Vivox로그인, 그룹 채널 참석을 한꺼번에 실행했기 때문이다.

 

검색을 해보니 "WebGL에서 마이크 접근 권한이 요청되지 않았거나 거부된 경우, Vivox 로그인이 실패할 수 있습니다." 라고 나왔다. 위의 코드는 WebGL의 마이크를 허용/차단을 선택을 하지않은 상태로 Start함수에서 한꺼번에 실행되기 때문이다.

 

그래서 버튼을 추가하여  서비스 초기화, Vivox로그인, 그룹 채널 참석을 분리를 하고 마이크를 허용인 상태에서 버튼을 순차적으로 실행 시켜서 테스트를 하였다. 그랬더니 WebGL에서 정상적으로 채널 참석을 할 수 있었다.

    [SerializeField] private Button _vivoxLogIn = null;
    [SerializeField] private Button _vivoxJoinChannel = null;
    
    private string _playerName = string.Empty;
    private const string _chanelName = "TestChannel";

    // Start is called before the first frame update
    async void Start()
    {
        await InitializeAsync();
        //await LoginToVivoxAsync();
        //await JoinGroupChannelAsync();
        //Debug.Log("채널 참석 완료!");
        
		// Vivox 로그인 버튼
        _vivoxLogIn.onClick.AddListener(async () => {
            await LoginToVivoxAsync();
        });
        // 채널 참석 버튼
        _vivoxJoinChannel.onClick.AddListener(async () => {
            await JoinGroupChannelAsync();
        });
    }
    
   //...

 

여기서는 추측이지만, await를 사용해 순차적으로 실행될 것으로 생각했지만, 분리하여 테스트한 결과, Vivox 내부 처리에서 제대로 동작하지 않는 문제가 있는 것으로 보였다. 이로 인해 작동하지 않는 것처럼 보였다.

 

그런데 버튼으로 눌러서 해결하는 방법은 내가 만들어야하는 프로그램의 로직상 유연하지 않는 방법이다.

내가 만들어야되는 음성채팅은 참여 버튼 하나를 누르면 음성 채팅 참가가 완료가 되어야 한다는 것이다. 보통은 사용자 입장에서 버튼 한번만 누르면 모든게 작동되어야 사용하기 편하다.

 

그래서 테스트를 이것저것 해보다보니 다음과 같은 결론이 나왔다. 이것은 음성채팅 샘플에도 똑같이 적용이 되어있다.

처리방법은 다음과 같다.

1. 서비스 초기화를 Awake에서 처리한다.

2. Vivox에 유저가 로그인 될 때 부르는 Vivox 유저 로그인이라는 콜백함수를 등록한다. 그리고 콜백함수에 그룹 채널 참가 함수를 넣는다.

3. Start 함수에서 vivoxLogIn이라는 음성채팅 참가 버튼을 만들어 Vivox를 로그인하는 콜백을 등록한다.

다음은 처리방법을 적용한 코드이다.

 

[SerializeField] private Button _vivoxLogIn = null;

private string _playerName = string.Empty;
private const string _chanelName = "TestChannel";

private async void Awake()
{
    await InitializeAsync();
}

// Start is called before the first frame update
private void Start()
{
    _vivoxLogIn.onClick.AddListener(async () => {
        await LoginToVivoxAsync();
    });
}

private void OnDestroy()
{
    VivoxService.Instance.LoggedIn -= OnUserLoggedIn;
    VivoxService.Instance.LoggedOut -= OnUserLoggedOut;

    VivoxService.Instance.ChannelJoined -= OnChannelJoined;
    VivoxService.Instance.ChannelLeft -= OnChannelLeft;
}

// 서비스 초기화
private async Task InitializeAsync()
{
    try
    {
        await UnityServices.InitializeAsync();
        Debug.Log($"Unity Services의 초기화를 성공하였습니다., UnityServices.State: [{UnityServices.State}]");

        await VivoxService.Instance.InitializeAsync();
        Debug.Log($"Vivox Services의 초기화를 성공하였습니다.");

        _playerName = PlayerNameLookUpTable();
        AuthenticationService.Instance.SwitchProfile(_playerName);
        await AuthenticationService.Instance.SignInAnonymouslyAsync();
        Debug.Log($"Unity Services의 익명 사용자 계정으로 로그인을 성공하였습니다. PlayerName: {_playerName}");

        VivoxService.Instance.LoggedIn += OnUserLoggedIn;
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"InitializeAsync Processing failed: {e.Message}");
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}
// Vivox 로그인
private async Task LoginToVivoxAsync()
    {
        try
        {
            LoginOptions options = new LoginOptions()
            {
                DisplayName = _playerName,
                ParticipantUpdateFrequency = ParticipantPropertyUpdateFrequency.FivePerSecond
            };
            await VivoxService.Instance.LoginAsync(options);

            Debug.Log($"Vivox의 로그인을 하였습니다.");
        }
        catch (ArgumentException e)
        {
            Console.WriteLine($"LoginToVivoxAsync Processing failed: {e.Message}");
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }
// 그룹 채널 참가
private async Task JoinGroupChannelAsync()
    {
        try
        {
            await VivoxService.Instance.JoinGroupChannelAsync(_chanelName, ChatCapability.AudioOnly);
            Debug.Log($"[{_chanelName}]의 그룹 채널을 참가하였습니다.");
        }
        catch (ArgumentException e)
        {
            Console.WriteLine($"JoinEchoChannelAsync Processing failed: {e.Message}");
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }
 // Vivox 유저 로그인
 async void OnUserLoggedIn()
    {
        Debug.Log("Vivox에 유저가 로그인되었습니다.");
        await JoinGroupChannelAsync();
    }

 

이런 순서로 코드를 짜야 WebGL에서 제대로 작동이된다.

 

참고로 다음과 같이 코드를 짜면 작동이 안된다. 그 예시이다.

    [SerializeField] private Button _vivoxLogIn = null;
    
    private string _playerName = string.Empty;
    private const string _chanelName = "TestChannel";

    private async void Awake()
    {
        await InitializeAsync();
    }

    // Start is called before the first frame update
    private void Start()
    {
        _vivoxLogIn.onClick.AddListener(async () => {
            await LoginToVivoxAsync();
            await JoinGroupChannelAsync();
        });
    }
    
    // ....

 

Start 함수에 vivox 로그인 및 채널 참가를 같이 넣으면 작동이 안된다. 

이유는 LoginTovivoxAsync() 뒤에 JoinGroupChannelAsync()이 실행이 안되는것인데, LoginTovivoxAsync() 에서 위에서 등록한 Vivox 유저 로그인까지는 내부적인 코드가 작동이되는걸로 보이는데, 그 이후의 코드가 작동이 되지않아 LoginTovivoxAsync()에서 반환을 받지못하기 때문에 JoinGroupChannelAsync()가 작동이 안되는걸로 보였다.

 

※ 참고사항

Vivox 유저 로그인 콜백 함수도 간헐적으로 반환이 되지 않아 로그인이 안되는 경우가 있다. 이 부분은 뭐가 문제인지 잘 모르겠다. 내부적인 코드가 잘못된것인지, 내가 잘못한 것인지...

추후 개선되면 글을 수정하도록 하자.

 

최종적으로 적용된 코드이다.

[SerializeField] private Button _vivoxLogIn = null;
[SerializeField] private Button _vivoxLogOut = null;
[SerializeField] private Button _vivoxJoinChannel = null;
[SerializeField] private Button _vivoxLeaveAllChannel = null;

private string _playerName = string.Empty;
private const string _chanelName = "TestChannel";

private async void Awake()
{
    await InitializeAsync();
}

// Start is called before the first frame update
private void Start()
{
    _vivoxLogIn.onClick.AddListener(async () => {
        await LoginToVivoxAsync();
    });
    _vivoxLogOut.onClick.AddListener(async () =>
    {
        await LogoutOfVivoxServiceAsync();
    });
    //_vivoxJoinChannel.onClick.AddListener(async () =>
    //{
    //    await JoinGroupChannelAsync();
    //});
    //_vivoxLeaveAllChannel.onClick.AddListener(async () =>
    //{
    //    await LeaveAllChannelsAsync();
    //});
}

private void OnDestroy()
{
    VivoxService.Instance.LoggedIn -= OnUserLoggedIn;
    VivoxService.Instance.LoggedOut -= OnUserLoggedOut;

    VivoxService.Instance.ChannelJoined -= OnChannelJoined;
    VivoxService.Instance.ChannelLeft -= OnChannelLeft;
}

// 서비스 초기화
private async Task InitializeAsync()
{
    try
    {
        await UnityServices.InitializeAsync();
        Debug.Log($"Unity Services의 초기화를 성공하였습니다., UnityServices.State: [{UnityServices.State}]");

        await VivoxService.Instance.InitializeAsync();
        Debug.Log($"Vivox Services의 초기화를 성공하였습니다.");

        _playerName = PlayerNameLookUpTable();
        AuthenticationService.Instance.SwitchProfile(_playerName);
        await AuthenticationService.Instance.SignInAnonymouslyAsync();
        Debug.Log($"Unity Services의 익명 사용자 계정으로 로그인을 성공하였습니다. PlayerName: {_playerName}");

        VivoxService.Instance.LoggedIn += OnUserLoggedIn;
        VivoxService.Instance.LoggedOut += OnUserLoggedOut;

        VivoxService.Instance.ChannelJoined += OnChannelJoined;
        VivoxService.Instance.ChannelLeft += OnChannelLeft;
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"InitializeAsync Processing failed: {e.Message}");
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}
// Vivox 로그인
private async Task LoginToVivoxAsync()
{
    try
    {
        LoginOptions options = new LoginOptions()
        {
            DisplayName = _playerName,
            ParticipantUpdateFrequency = ParticipantPropertyUpdateFrequency.FivePerSecond
        };
        await VivoxService.Instance.LoginAsync(options);

        Debug.Log($"Vivox의 로그인을 하였습니다.");
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"LoginToVivoxAsync Processing failed: {e.Message}");
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}
// Vivox 로그아웃
private async Task LogoutOfVivoxServiceAsync()
{
    try
    {
        await VivoxService.Instance.LogoutAsync();
        Debug.Log($"Vivox의 로그아웃을 하였습니다.");
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"LogoutOfVivoxServiceAsync Processing failed: {e.Message}");
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}
// 에코 채널 참가
private async Task JoinEchoChannelAsync()
{
    try
    {
        await VivoxService.Instance.JoinEchoChannelAsync(_chanelName, ChatCapability.AudioOnly);
        Debug.Log($"Vivox의 에코 채널을 참가하였습니다.");
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"JoinEchoChannelAsync Processing failed: {e.Message}");
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}
// 그룹 채널 참가
private async Task JoinGroupChannelAsync()
{
    try
    {
        await VivoxService.Instance.JoinGroupChannelAsync(_chanelName, ChatCapability.AudioOnly);
        Debug.Log($"[{_chanelName}]의 그룹 채널을 참가하였습니다.");
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"JoinEchoChannelAsync Processing failed: {e.Message}");
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}
// 모든 채널 나가기
private async Task LeaveAllChannelsAsync()
{
    try
    {
        await VivoxService.Instance.LeaveAllChannelsAsync();
        Debug.Log($"Vivox의 모든 채널을 나갔습니다.");
    }
    catch (ArgumentException e)
    {
        Console.WriteLine($"LeaveAllChannelsAsync Processing failed: {e.Message}");
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}

async void OnUserLoggedIn()
{
    Debug.Log("Vivox에 유저가 로그인되었습니다.");
    await JoinGroupChannelAsync();
}
void OnUserLoggedOut()
{
    Debug.Log("Vivox의 유저가 로그아웃되었습니다.");
}

void OnChannelJoined(string channel)
{
    Debug.Log($"[{channel}] 채널에 참석하였습니다.");
}
void OnChannelLeft(string channel)
{
    Debug.Log($"[{channel}] 채널을 나갔습니다.");
}

string PlayerNameLookUpTable()
{
    string[] table = new string[] { "Editor", "Window", "Android", "WebGL" };
    string result = string.Empty;
    switch (Application.platform)
    {
        case RuntimePlatform.WindowsEditor:
            {
                result = table[0];
                break;
            }
        case RuntimePlatform.WindowsPlayer:
            {
                result = table[1];
                break;
            }
        case RuntimePlatform.Android:
            {
                result = table[2];
                break;
            }
        case RuntimePlatform.WebGLPlayer:
            {
                result = table[3];
                break;
            }
    }

    return result;
}

 

 

Blender에서 xxx.blend 파일로 애니메이션을 전체 관리하고 있다.

Export 시 활성화된 오브젝트만 선택하여 Unity로 내보내고 있다.
하지만 애니메이션은 가끔씩 작업하는 경우가 많아 기억이 희미해져, 다음과 같은 문제로 어려움을 겪었다.
이에 대해 기록으로 남기기로 했다.

 

※ 문제의 Export

- 블렌더

Export할 객체의 부모만 선택하여 블렌더에서 Export를 했다.

 

-유니티

위와 같이 Blender에서 FBX를 내보내면, Bone 데이터가 정상적으로 생성되지 않는 문제가 발생한다.

이유는 Blender에서 Export 시 'Selected Objects' 옵션을 사용하여 내보내는데, 애니메이션에 사용된 Bone 데이터가 제대로 포함되지 않기 때문이다.

 

그래서 다음과 같이 내보내야 된다.

 

- 결론

 

내보내기할 오브젝트를 모두 선택하고 Selected objects로 내보낸다.

 

추가적으로 FBX를 내보내기 옵션은 위에 나온대로 내보내기 하면 된다.

참고자료

https://catlikecoding.com/unity/tutorials/procedural-meshes/creating-a-mesh/

 

Creating a Mesh

A Unity C# Procedural Meshes tutorial about creating a mesh via code, with the simple and advanced Mesh API.

catlikecoding.com

https://www.youtube.com/watch?v=Q12sb-sOhdI&list=RDCMUCmtyQOKKmrMVaKuRXz02jbQ&index=4

 

결과물

 

코딩

나중에 정리 할 것

참고 사이트

https://catlikecoding.com/unity/tutorials/procedural-meshes/creating-a-mesh/

 

Creating a Mesh

A Unity C# Procedural Meshes tutorial about creating a mesh via code, with the simple and advanced Mesh API.

catlikecoding.com

 

코딩1(일반적인 방법)

 

 

코딩2(고급 API 방법)

 

※ 텍스처

 

나중에 설명 적기

참고자료

https://www.youtube.com/watch?v=RF04Fi9OCPc&list=PLFt_AvWsXl0d8aDaovNztYf6iTChHzrHP

 

https://ko.javascript.info/bezier-curve

 

베지어 곡선

 

ko.javascript.info

 

결과물

 

코드

 

베지어 곡선

 

공부하고 추가 보완 작업 할 것

+ Recent posts