이전 챕터에서 JUCE로 간단하게 윈도우를 생성하고 이를 구현하는 내부 코드들을 간단히 살펴보았다. 이번 챕터에서는 JUCE에서 기본적으로 제공하는 GUI Component들에 대해 살펴보고 이것들의 내부 코드 또한 하나씩 살펴보는 시간을 갖고자 한다.
GUI Component란 Button, Slider, ComboBox, TableView, TreeView등의 Graphic User Interface 구성요소를 통틀어 말하는데 GUI 프로그래밍을 지원하는 Framework라면 기본적으로 이 Component를 구현해놓고 있다. JUCE Framework 역시 굉장히 다양하고 훌륭한 GUI Component들을 제공하는데 이것들의 코드를 살펴보고 어떻게 동작하는지 구조를 간략히 파악해보면서 JUCE Framework 이해에 한걸음 더 다가가 보고자 한다.
1) Projucer를 이용한 GUI Component 배치
첫번째 챕터를 통해 Projucer를 이용한 IDE Project 파일을 만드는 것을 실습해보았는데, 사실 Projucer에는 이것 말고도 훌륭한 기능이 한 가지 더있다! 바로 WYSIWYG 스타일로 즉, 코드를 치지 않고도 Drag&Drop만으로도 GUI Component를 배치할 수 있다는 것이다. 원하는 Component를 선택해서 원하는 위치에 배치하면 Projucer가 알아서 그에 해당하는 C++코드를 생성해준다. 설명이 길면 재미없으니깐 얼른 만지작거리러 가보자.
File explorer 탭을 열고 오른쪽 버튼을 클릭하면 다음과 같은 항목들이 등장한다. 우리는 여기서 두번째 블록 맨 아래의 [Add New GUI Component...] 라는 항목을 선택할 것이다. 그러면 새로운 소스파일을 저장할 경로를 선택하는 창이 뜰 것이다. Source 폴더의 아래로 들어가 원하는 이름을 붙여주자. 나는 NewComponent라는 이름을 붙이기로 했다.
그러면 자동으로 우리가 설정한 이름을 가진 헤더파일과 CPP파일이 각각 생성된 것이 보인다. 재밌는 점은 CPP파일을 선택하면 코드를 보여주지 않고 이런 탭메뉴를 보여준다는 것이다. 첫번째 탭은 일단 건너 뛰고 빨리 두번째 SubComponent Tab을 열어보자 빨리빨리.
여기서 오른쪽 마우스를 클릭
모눈종이같은 공간을 오른쪽 클릭하면 우리가 여기에 추가할 수 있는 GUI Component의 종류들이 좕 뜬다. 가장 위에 있는 New Text Button을 클릭해서 새로운 Text Button Component를 만들어보자. 그러면 우리의 첫 GUI Component가 드디어 세상의 빛을 보게 될 것이다. 이렇게 만들어진 button에 대한 속성은 오른쪽에 모두 나타나있다. member name은 코드 상에서 이 버튼에 접근할 수 있는 이름이고 name은 이 button의 속성으로 저장되어 Runtime에서 접근할 수 있는 정보이다. virtual class는 이 Component의 선언 타입인데, 따로 값을 입력하지 않으면 기본값으로 TextButton 이라는 타입으로 선언이 된다. 이 아래로는 모두 이 버튼의 외관을 결정하는 속성들이니 하나씩 건드려보며 어떻게 변하는지 직접 느껴보면 좋다.
지금까지의 상태를 간단히 짚고 넘어가자면, 우리는 NewComponent라는 객체를 새롭게 만들었고 그 안에 TextButton 객체를 추가했다. NewComponent와 TextButton는 서로가 부모 자식 관계에 놓인 객체인 것이다. 그렇다면 저 오른쪽 속성창에 있는 Generate ButtonListener라는 체크박스는 무엇을 뜻할까? 이 TextButton이 눌렸을 때 발생하는 Event를 부모객체인 NewComponent가 수신하도록 한다는 뜻이다. 그림으로 표현하자면 이러하다.
0xFF00CA80은 아무렇게나 적어본 NewComponent의 메모리 주소이다.
Button은 NewComponent의 멤버 중 하나인데, 이때 Button은 buttonListeners라는 List에 자신의 부모 즉, NewComponent의 포인터를 담고 있다. Button이 사용자로부터 mouseDown Event를 받으면 buttonListeners의 모든 멤버가 buttonClicked 메서드를 호출하도록 되어있다. 그리고 이 과정은 Button에서 mouseDown이 발생하는 순간 Synchronous하게 이뤄진다. 이 동작에 대해서는 아래에서 코드를 보며 다시 한 번 살펴보겠다. 일단은 NewComponent에 대해 좀 더 자세한 설명을 위해 급하게 건너뛰었던 첫번째 탭으로 돌아가보겠다.
첫번째 탭인 'Class'에서는 이 Component의 전반적인 속성을 설정할 수 있다. Class name은 이 Component의 실제 이름을 가리키며, 코드상에서 접근할 수 있는 이름이다. Template file에는 이러한 설정 등을 통해 자동으로 생성되는 코드의 Template을 설정한 file 경로를 입력할 수 있는데 기본적으로도 이미 Template이 잘 갖춰져 있기 때문에 필수적이지는 않다. Component name은 이 Component가 내부적으로 갖게되는 이름인데 Attribute로 지니고 있기 때문에 Runtime에서도 접근할 수 있는 정보이다.
그 다음에 있는 Parent classes라는 이름을 주목해보자. 단수가 아닌 복수로 쓰인 이유는 바로 NewComponent가 이 항목에 입력되는 Class들을 모두 상속받게 된다는 뜻이다. 지금은 'Component'라는 클래스만을 상속받도록 되어있지만 콤마(,)를 이용해 더 추가할 수도 있다. 이것을 이해했다면 아래의 두 항목도 용도가 쉽게 유추된다. Constructor params는 이 Component Class의 생성자가 받을 인자들에 대한 정보이고, Member initialisers는 생성자 호출시 초기화 해줄 멤버를 입력하는 곳이다.
그리고 아래의 나머지는 이 Component의 높이와 너비를 입력하고 크기를 고정시킬건지 유동적으로 변화시킬 것인지를 설정하는 곳이다.
오른쪽에 있는 리스트들 또한 매우 흥미를 당긴다. 이름을 통해 유추해보자면, 이 Component에 어떤 이벤트가 발생했을 때의 처리여부와 그 때 실행할 콜백함수에 대한 정보가 담겨있다. 마우스가 눌렸을 때, 다른 Component들과의 토폴로지가 변경됐을 때, 자식 Component에 변화가 발생했을 때 등 엄청나게 많은 Event들을 등록할 수 있게 되어있다. 이 NewComponent 객체가 이전 탭에서 추가했던 ButtonListener이외에도 수많은 Event들을 수신할 수 있다는 뜻이다. 이것들만 잘 이용해도 상당히 동적인 반응형 어플리케이션을 구현할 수 있다. 이 Event를 처리하는 것에 대해서도 역시 이후에 좀 더 자세히 다뤄보겠다.
일단은 이 버튼이 추가된 창을 빨리 실행해보고 싶을 뿐이다. 상단의 Xcode(혹은 Visual Studio 등)의 아이콘을 눌러 IDE로 넘어간 뒤 빌드하고 실행을 해보자.
버튼을 추가했지만 어째선지 윈도우에는 변함없이 인사만 건네고 있다. 우리가 만들었던 버튼은 어디로 가있는 걸까? 결론부터 말하자면 버튼이 코드 상으로는 존재하지만 실제 윈도우 위에 로드를 하지 않았기 때문에 보이지 않는 것이다. 이제 우리가 지난 챕터에서 모른척 하고 어물쩡 넘어가버렸던 Main.cpp 파일을 열어볼 차례이다.
/* ============================================================================== This file was auto-generated! It contains the basic startup code for a JUCE application. ============================================================================== */ #include "../JuceLibraryCode/JuceHeader.h" #include "MainComponent.h" // -> "NewComponent.h" //============================================================================== class JUCEPractice1Application : public JUCEApplication { public: //============================================================================== JUCEPractice1Application() {} const String getApplicationName() override { return ProjectInfo::projectName; } const String getApplicationVersion() override { return ProjectInfo::versionString; } bool moreThanOneInstanceAllowed() override { return true; } //============================================================================== void initialise (const String& commandLine) override { // This method is where you should put your application's initialisation code.. mainWindow = new MainWindow (getApplicationName()); } void shutdown() override { // Add your application's shutdown code here.. mainWindow = nullptr; // (deletes our window) } //============================================================================== void systemRequestedQuit() override { // This is called when the app is being asked to quit: you can ignore this // request and let the app carry on running, or call quit() to allow the app to close. quit(); } void anotherInstanceStarted (const String& commandLine) override { // When another instance of the app is launched while this one is running, // this method is invoked, and the commandLine parameter tells you what // the other instance's command-line arguments were. } //============================================================================== /* This class implements the desktop window that contains an instance of our MainComponent class. */ class MainWindow : public DocumentWindow { public: MainWindow (String name) : DocumentWindow (name, Desktop::getInstance().getDefaultLookAndFeel() .findColour (ResizableWindow::backgroundColourId), DocumentWindow::allButtons) { setUsingNativeTitleBar (true); setContentOwned (new MainComponent(), true);// -> setContentOwned (new NewComponent(), true); centreWithSize (getWidth(), getHeight()); setVisible (true); } void closeButtonPressed() override { // This is called when the user tries to close this window. Here, we'll just // ask the app to quit when this happens, but you can change this to do // whatever you need. JUCEApplication::getInstance()->systemRequestedQuit(); } /* Note: Be careful if you override any DocumentWindow methods - the base class uses a lot of them, so by overriding you might break its functionality. It's best to do all your work in your content component instead, but if you really have to override any DocumentWindow methods, make sure your subclass also calls the superclass's method. */ private: JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainWindow) }; private: ScopedPointer<MainWindow> mainWindow; }; //============================================================================== // This macro generates the main() routine that launches the app. START_JUCE_APPLICATION (JUCEPractice1Application)
이전까지 봤던 JUCE 코드들과는 다르게 뭔가 무시무시하다. 하지만 겁먹을 필요는 없다. 우리가 신경써야 할 라인은 12번째 줄과 69번째 줄 뿐이다. 12)에서는 앞서 만들어져 있던 MainComponent에 대한 정보가 담긴 헤더파일을 코드에 추가하고, 69)에서는 앞서 선언된 MainComponent를 생성하고 있다. 이 코드들만 각각 NewComponent로 교체해주면 된다. 이제 다시 빌드하고 실행을 해보자.
짜잔잔 버튼이 생겨났다. 클릭해도 아무 일은 일어나지 않지만 그래도 재밌고 신기하다. 이제 저 버튼이 생겨나기까지 코드에서는 무슨 일이 있었는지 알아보러 얼른 가보자.
2) NewComponent.h
/* ============================================================================== This is an automatically generated GUI class created by the Projucer! Be careful when adding custom code to these files, as only the code within the "//[xyz]" and "//[/xyz]" sections will be retained when the file is loaded and re-saved. Created with Projucer version: 5.3.1 ------------------------------------------------------------------------------ The Projucer is part of the JUCE library. Copyright (c) 2017 - ROLI Ltd. ============================================================================== */ #pragma once //[Headers] -- You can add your own extra header files here -- #include "../JuceLibraryCode/JuceHeader.h" //[/Headers] //============================================================================== /** //[Comments] An auto-generated component, created by the Projucer. Describe your class and how it works here! //[/Comments] */ class NewComponent : public Component, public Button::Listener { public: //============================================================================== NewComponent (); ~NewComponent(); //============================================================================== //[UserMethods] -- You can add your own custom methods in this section. //[/UserMethods] void paint (Graphics& g) override; void resized() override; void buttonClicked (Button* buttonThatWasClicked) override; private: //[UserVariables] -- You can add your own custom variables in this section. //[/UserVariables] //============================================================================== ScopedPointer<TextButton> textButton; // std::unique_ptr<TextButton> textButton; //============================================================================== JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NewComponent) }; //[EndFile] You can add extra defines here... //[/EndFile]
NewComponent의 헤더파일을 열어보자. 우선은 Component 클래스만 상속 받다가 Button::Listener 클래스도 추가로 상속받는 것을 확인해볼 수 있다. 더불어 Button::Listener에 선언되어 있는 buttonClicked 메소드도 재정의하도록 추가됐다. 그리고 우리가 화면에 새롭게 배치했던 textButton 멤버도 private영역에 새롭게 선언되어 있다. 흥미로운 것은 TextButton이 ScopedPointer의 Generic Type으로 선언되어 있다는 점인데, ScopedPointer 클래스는 C++표준에서 제공하는 스마트포인터와 같은 역할을 한다. 그래서 그런지는 몰라도 가장 최신버전인 JUCE 5.3.2버전 부터 Deprecate되어 더 이상 사용되지 않는다. 하지만 이 튜토리얼은 5.3.1버전으로 작성되었기 때문에 일단은 저것이 스마트포인터와 똑같다는 것만 알고 넘어가면 되겠다.
스마트포인터로 버튼을 선언한 이유는 간단하다. NewComponent에 속해있는 객체이기 때문에 NewComponent가 소멸되면 TextButton객체 역시 더 이상 어디에도 필요가 없기 때문에 Composition 형태로 NewComponent에 종속시키려는 목적이다. 처음부터 포인터 타입이 아니라 일반 멤버 변수로 선언하면 ('TextButton textButton;' 이런식으로) 어차피 NewComponent가 소멸될 때 알아서 메모리가 해제되지 않겠냐는 질문을 던져볼 수도 있겠다. 그것에 대한 대답은 NewComponent.cpp에 담겨있다.
3) NewComponent.cpp
/* ============================================================================== This is an automatically generated GUI class created by the Projucer! Be careful when adding custom code to these files, as only the code within the "//[xyz]" and "//[/xyz]" sections will be retained when the file is loaded and re-saved. Created with Projucer version: 5.3.1 ------------------------------------------------------------------------------ The Projucer is part of the JUCE library. Copyright (c) 2017 - ROLI Ltd. ============================================================================== */ //[Headers] You can add your own extra header files here... //[/Headers] #include "NewComponent.h" //[MiscUserDefs] You can add your own user definitions and misc code here... //[/MiscUserDefs] //============================================================================== NewComponent::NewComponent () { //[Constructor_pre] You can add your own custom stuff here.. //[/Constructor_pre] addAndMakeVisible (textButton = new TextButton ("new button")); textButton->addListener (this); textButton->setBounds (225, 188, 150, 24); //[UserPreSize] //[/UserPreSize] setSize (600, 400); //[Constructor] You can add your own custom stuff here.. //[/Constructor] } NewComponent::~NewComponent() { //[Destructor_pre]. You can add your own custom destruction code here.. //[/Destructor_pre] textButton = nullptr; //[Destructor]. You can add your own custom destruction code here.. //[/Destructor] } //============================================================================== void NewComponent::paint (Graphics& g) { //[UserPrePaint] Add your own custom painting code here.. //[/UserPrePaint] g.fillAll (Colour (0xff323e44)); //[UserPaint] Add your own custom painting code here.. //[/UserPaint] } void NewComponent::resized() { //[UserPreResize] Add your own custom resize code here.. //[/UserPreResize] //[UserResized] Add your own custom resize handling here.. //[/UserResized] } void NewComponent::buttonClicked (Button* buttonThatWasClicked) { //[UserbuttonClicked_Pre] //[/UserbuttonClicked_Pre] if (buttonThatWasClicked == textButton) { //[UserButtonCode_textButton] -- add your button handler code here.. //[/UserButtonCode_textButton] } //[UserbuttonClicked_Post] //[/UserbuttonClicked_Post] } //[MiscUserCode] You can add your own definitions of your custom methods or any other code here... //[/MiscUserCode] #endif //[EndFile] You can add extra defines here... //[/EndFile]
생성자의 코드의 첫 줄을 살펴보면 addAndMakeVisible이라는 함수를 호출과 동시에 textButton에 메모리를 할당하는 것을 볼 수 있다. 그리고 추가적으로 textButton의 초기화를 위한 작업들이 아래에 이어 등장한다. 클래스 타입의 멤버를 포인터 변수로 선언했을 때의 장점은 초기화 시점을 개발자가 직접 정할 수 있다는 점이다. 지금은 NewComponent 안에 TextButton Component만 있지만 여러개의 하위 Component들이 선언되어 있고 이들이 서로 어떠한 종속관계를 맺어야 한다면 생성자에 모든 것을 맡기기 보다는 비교적 덜 안전하더라도 개발자가 직접 초기화 시점을 정할 수 있도록 스마트포인터 형태로 만들어 주는 것이다. 그래도 56)을 보면 코드의 명료함을 위해 소멸자에서 스마트포인터의 메모리를 수동으로 해제하고 있다.
35)에서는 textButton->addListener(this)라는 함수를 통해 textButton이 어떤 Event를 받았을 때, 그 Event를 받았다는 사실을 알려주도록 NewComponent를 textButton의 Listener에 등록하고 있다. 그렇다면 이것이 어떻게 버튼이 눌렸을 때 NewComponent의 buttonClicked가 호출되도록 동작하는 것일까? Button의 내부 코드를 한번 쫒아가 보자.
/* juce_Button.cpp */ void Button::mouseDown (const MouseEvent& e) { updateState (true, true); if (isDown()) { if (autoRepeatDelay >= 0) callbackHelper->startTimer (autoRepeatDelay); if (triggerOnMouseDown) internalClickCallback (e.mods); // <-- 여러 복잡한 과정을 지나 아무튼 이 함수가 호출된다. } }
Button은 Component 클래스를 상속받아서 마우스가 눌릴 때 불리는 mouseDown Callback을 재정의 했다.
/* juce_Button.cpp */ void Button::internalClickCallback (const ModifierKeys& modifiers) { if (clickTogglesState) { const bool shouldBeOn = (radioGroupId != 0 || ! lastToggleState); if (shouldBeOn != getToggleState()) { setToggleState (shouldBeOn, sendNotification); return; } } sendClickMessage (modifiers); // <-- 그랬더니 또 이런저런 복잡한 과정을 지나 여차저차 이 함수가 호출된다. }
/* juce_Button.cpp */ void Button::sendClickMessage (const ModifierKeys& modifiers) { Component::BailOutChecker checker (this); if (commandManagerToUse != nullptr && commandID != 0) { ApplicationCommandTarget::InvocationInfo info (commandID); info.invocationMethod = ApplicationCommandTarget::InvocationInfo::fromButton; info.originatingComponent = this; commandManagerToUse->invoke (info, true); } clicked (modifiers); if (checker.shouldBailOut()) return; buttonListeners.callChecked (checker, [this] (Listener& l) { l.buttonClicked (this); }); //<-- 이곳에서 buttonListener 안에 등록된 Listener들의 buttonClicked를 호출해준다. if (checker.shouldBailOut()) return; if (onClick != nullptr) onClick(); }
buttonListeners는 addListener를 통해 인자로 넘어온 것들을 저장하는 List 타입의 멤버이다. sendClickMessage의 20)에서 buttonListeners 안의 멤버가 모두 buttonClicked를 호출하도록 하는 것을 확인할 수 있다. 이 때 Callback 인자로 자신을 넘겨주는 것을 확인할 수 있다. 그 이유는 아래에서 설명하도록 하겠다.
36)에서는 textButton이 newComponent에서 어느 곳에 위치하는지 왼쪽 위의 좌표(x,y)와 높이 너비(width, height)를 차례로 설정해준다. 이전 편에서 잠시 살펴봤던 resized를 떠올려보자.
virtual void Component::resized ( )
A component can implement this method to do things such as laying out its child components when its width or height changes. The method is called synchronously as a result of the setBounds or setSize methods, so repeatedly changing a components size will repeatedly call its resized method (unlike things like repainting, where multiple calls to repaint are coalesced together). If the component is a top-level window on the desktop, its size could also be changed by operating-system factors beyond the application's control.
이 Callback은 setBounds나 setSize가 호출되었을 때 syncronous하게 실행된다고 하였다. 36)이 실행되었을 때 역시 자동으로 textButton의 resize가 이어서 실행될 것임을 예상할 수 있다.
4) Event 등록
버튼도 만들어 봤으니 이제는 버튼이 눌렸을 때(Event) 실제로 어떤 일이 일어나도록(Callback) 해보겠다. Button이 눌린 횟수를 기록해서 화면에 표시해주는 기능을 만들어볼 것이다. 우선은 다시 Projucer로 돌아가보자.
이번에는 Text Button이 아닌 Label을 새로 추가해보겠다. stateLabel이라는 멤버 이름을 정해주고 기본 값으로 'Nothing Happend'라는 String을 띄워주도록 설정한다. 이것이 코드로는 다음과 같이 반영되어 있을 것이다. Label Component가 추가되면서 어떤 코드가 새로 추가됐는지 유심히 살펴보길 바란다.
/* NewComponent.h */ class NewComponent : public Component, public Button::Listener { public: //============================================================================== NewComponent (); ~NewComponent(); //============================================================================== //[UserMethods] -- You can add your own custom methods in this section. //[/UserMethods] void paint (Graphics& g) override; void resized() override; void buttonClicked (Button* buttonThatWasClicked) override; private: //[UserVariables] -- You can add your own custom variables in this section. int cnt; //[/UserVariables] //============================================================================== ScopedPointer<TextButton> textButton; ScopedPointer<Label> stateLabel; //============================================================================== JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NewComponent) };
/* NewComponent.cpp */ NewComponent::NewComponent () { //[Constructor_pre] You can add your own custom stuff here.. //[/Constructor_pre] addAndMakeVisible (textButton = new TextButton ("new button")); textButton->addListener (this); textButton->setBounds (225, 188, 150, 24); addAndMakeVisible (stateLabel = new Label ("new label", TRANS("Nothing Happend"))); stateLabel->setFont (Font (15.00f, Font::plain).withTypefaceStyle ("Regular")); stateLabel->setJustificationType (Justification::centredLeft); stateLabel->setEditable (false, false, false); stateLabel->setColour (TextEditor::textColourId, Colours::black); stateLabel->setColour (TextEditor::backgroundColourId, Colour (0x00000000)); stateLabel->setBounds (224, 152, 150, 24); //[UserPreSize] //[/UserPreSize] setSize (600, 400); //[Constructor] You can add your own custom stuff here.. cnt = 0; //[/Constructor] }
버튼이 눌린 횟수를 기록하기 위해 헤더파일과 소스파일의 22)와 30)에 integer 변수 cnt를 선언했다. 주의해야 할 점은 이런 template Block의 바깥에 코드를 작성하면 Projucer에서 코드를 자동으로 생성할 때 모두 지워진다는 것이다. 나도 처음에는 이걸 모르고 Projucer를 사용했다가 여러번 낭패를 봤었다. 친절하게도 Block이 굉장히 세분화돼서 만들어져 있기 때문에 규칙만 잘 지키면 얼마든지 자유롭게 코딩할 수 있다. 이제는 버튼의 Event가 발생했을 때 호출되는 Callback 함수 buttonClicked로 가보자.
void NewComponent::buttonClicked (Button* buttonThatWasClicked) { //[UserbuttonClicked_Pre] //[/UserbuttonClicked_Pre] if (buttonThatWasClicked == textButton) { //[UserButtonCode_textButton] -- add your button handler code here.. cnt++; stateLabel->setText("Clicked " + String(cnt) + " times"); //[/UserButtonCode_textButton] } //[UserbuttonClicked_Post] //[/UserbuttonClicked_Post] }
NewComponent는 textButton이외에도 다른 ButtonListener에 등록될 수 있다. 현재는 textButton 하나에만 등록되어 있지만 만약에 버튼이 여러개가 있고 그 버튼 모두의 Event에 Listener로 등록되더라도 모두 공통적으로 buttonClicked라는 이 Callback을 호출할 것이다. 그렇기 때문에 Callback 인자로 어느 버튼이 눌린 것인지 알려주고 있다.
이를 통해 NewComponent가 가진 Button 멤버들과 메모리 주소를 비교하여 어떤 버튼인지 식별한 뒤 각각에 해당하는 동작을 수행하는 것이다. 6)에서도 이와 같은 방법으로 textButton임을 식별하여 9)눌린 횟수를 증가시켜주고 10)stateLabel의 텍스트를 업데이트한다. 이제 준비는 다 되었다. 빌드를 한 뒤 실행해서 잘 동작하는 것을 확인해보자.
열심히 누른 만큼 카운터도 정직하게 올라간다. 세상 모든 일이 이렇게 정직하면 좋으련만.
'개발 > JUCE Framework' 카테고리의 다른 글
[3D 그래픽 엔진을 만들어보자] - 4.ViewPort의 이해 (0) | 2018.07.29 |
---|---|
[3D 그래픽 엔진을 만들어보자] - 2. JUCE Framework의 기본적인 구조 (0) | 2018.06.28 |
[3D 그래픽 엔진을 만들어보자] - 1. Juce Framework 튜토리얼 (0) | 2018.06.28 |
[3D 그래픽 엔진을 만들어보자] - 0. 들어가며 (2) | 2018.06.28 |