|
| 1 | +<!-- |
| 2 | +SPDX-License-Identifier: MPL-2.0 |
| 3 | +
|
| 4 | +This file is part of Ramses Composer |
| 5 | +(see https://github.com/bmwcarit/ramses-composer-docs). |
| 6 | +
|
| 7 | +This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. |
| 8 | +If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. |
| 9 | +--> |
| 10 | +# Writing an Android app |
| 11 | + |
| 12 | + |
| 13 | + |
| 14 | +This tutorial explains how to integrate a Ramses Composer asset into your Android application. We will use the asset from the |
| 15 | +[Monkey tutorial](../monkey/README.md) and integrate it into an android application. |
| 16 | + |
| 17 | +We don't want to repeat the Android documentation here. If you are new to Android, please |
| 18 | +have a look at the [official docs]() which explain the various aspects of Android. In this |
| 19 | +tutorial, we assume you have basic understanding of Android and will focus only on Ramses |
| 20 | +specifics. |
| 21 | + |
| 22 | +If you don't like reading, you can find the final source code of the app in the [app_src](./app_src) |
| 23 | +folder. |
| 24 | + |
| 25 | +## Creating an empty Android app |
| 26 | + |
| 27 | +Create a new project in Android Studio. Select "empty activity". In this tutorial, we |
| 28 | +use MinSDK=26, use Kotlin as project language. |
| 29 | + |
| 30 | +## Adding Ramses as dependency |
| 31 | + |
| 32 | +Next, we can add the Ramses AAR as a dependency in the app-level `build.gradle` file, like this: |
| 33 | + |
| 34 | +```groovy |
| 35 | +// <root>/app/build.gradle |
| 36 | +dependencies |
| 37 | +{ |
| 38 | + implementation "io.github.bmwcarit:ramses-aar:1.0.3" |
| 39 | +} |
| 40 | +``` |
| 41 | + |
| 42 | +We also have to disable compression for ramses assets in the app: |
| 43 | + |
| 44 | +```groovy |
| 45 | +android { |
| 46 | + aaptOptions { |
| 47 | + noCompress "ramses", "rlogic" |
| 48 | + } |
| 49 | +} |
| 50 | +``` |
| 51 | + |
| 52 | +This is needed because Ramses already compresses assets which collides with |
| 53 | +the Android mechanism for asset compression. |
| 54 | + |
| 55 | +## Creating a scene thread |
| 56 | + |
| 57 | +Next, we create a thread class which loads and visualizes the Ramses assets. To do so, we can extend |
| 58 | +the RamsesThread with our custom logic, we will |
| 59 | +call it MonkeyThread in this example: |
| 60 | + |
| 61 | +```kotlin |
| 62 | + |
| 63 | +import com.bmwgroup.ramses.RamsesThread |
| 64 | + |
| 65 | +class MonkeyThread(threadName: String, context: Context) : |
| 66 | + RamsesThread(threadName, context) { |
| 67 | + |
| 68 | + // We will be adding the implementation of these methods a bit later! |
| 69 | + override fun onSceneLoaded() = Unit |
| 70 | + override fun onUpdate() = Unit |
| 71 | + override fun onSceneLoadFailed() = Unit |
| 72 | + override fun onLogicUpdated() = Unit |
| 73 | + override fun onDisplayResize(width: Int, height: Int) = Unit |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +Override all required methods and leave them empty for now, we will be returning back to this |
| 78 | +code shortly. |
| 79 | + |
| 80 | +## Replace the TextView with a SurfaceView in the Activity layout |
| 81 | + |
| 82 | +Replace the default TextView created by Android in our activity layout XML file with a |
| 83 | +SurfaceView which we will render our monkey into, like this: |
| 84 | + |
| 85 | +```xml |
| 86 | + <SurfaceView |
| 87 | + android:id="@+id/surfaceView" |
| 88 | + android:layout_width="0dp" |
| 89 | + android:layout_height="0dp" |
| 90 | + app:layout_constraintBottom_toBottomOf="parent" |
| 91 | + app:layout_constraintEnd_toEndOf="parent" |
| 92 | + app:layout_constraintStart_toStartOf="parent" |
| 93 | + app:layout_constraintTop_toTopOf="parent" /> |
| 94 | +``` |
| 95 | + |
| 96 | +Ramses supports rendering into a SurfaceView and a TextureView. We are going to be using the |
| 97 | +former in this example. |
| 98 | + |
| 99 | +Next, we make our activity listen to surface events, so that we |
| 100 | +can load our asset files after a surface is created, and adapt its size when the |
| 101 | +surface size is changed. First, implement the SurfaceHolder.Callback interface: |
| 102 | + |
| 103 | +```kotlin |
| 104 | +class MainActivity : AppCompatActivity(), SurfaceHolder.Callback { |
| 105 | + // We will implement these later! |
| 106 | + override fun surfaceCreated(holder: SurfaceHolder) = Unit |
| 107 | + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit |
| 108 | + override fun surfaceDestroyed(holder: SurfaceHolder) = Unit |
| 109 | +} |
| 110 | +``` |
| 111 | + |
| 112 | +...and register the activity as a listener to surface events in the activity constructor: |
| 113 | + |
| 114 | +```kotlin |
| 115 | +val surfaceView = findViewById<SurfaceView>(R.id.surfaceView) |
| 116 | +surfaceView.holder.addCallback(this) |
| 117 | +``` |
| 118 | + |
| 119 | +If we start the app now, we will only see a black surface - we are still not doing anything |
| 120 | +meaningful with the surface. |
| 121 | + |
| 122 | +## Exporting a binary version of the asset |
| 123 | + |
| 124 | +In order to render something into the SurfaceView, we need an asset exported in binary form from |
| 125 | +the RamsesComposer. |
| 126 | +There are multiple ways how to do this, the simplest one is to use the export menu (File -> Export). |
| 127 | +Open the project we created in the [Monkey tutorial](../monkey/README.md) and export the files |
| 128 | +into the assets/ folder of the app. If you used the standard paths, this is \<root\>/app/src/main/assets/. |
| 129 | + |
| 130 | +## Loading the assets |
| 131 | + |
| 132 | +Let's got back to the activity. We can now instantiate the MonkeyThread we created earlier and |
| 133 | +load the assets into it. |
| 134 | + |
| 135 | +One way to do this is to use the 'lazy' initialization mechanism of Kotlin to instantiate the |
| 136 | +thread, and to |
| 137 | +initialize it in the onCreate() callback of our activity, like this: |
| 138 | + |
| 139 | + |
| 140 | +```kotlin |
| 141 | +class MainActivity : AppCompatActivity(), SurfaceHolder.Callback { |
| 142 | + private val monkeyThread: MonkeyThread by lazy { |
| 143 | + MonkeyThread("MonkeyThread", applicationContext) |
| 144 | + } |
| 145 | + |
| 146 | + override fun onCreate(savedInstanceState: Bundle?) { |
| 147 | + super.onCreate(savedInstanceState) |
| 148 | + setContentView(R.layout.activity_main) |
| 149 | + |
| 150 | + // Loads the binary asset files we exported before using the asset manager |
| 151 | + monkeyThread.initRamsesThreadAndLoadScene(assets, "monkey.ramses", "monkey.rlogic") |
| 152 | + |
| 153 | + val surfaceView = findViewById<SurfaceView>(R.id.surfaceView) |
| 154 | + surfaceView.holder.addCallback(this) |
| 155 | + } |
| 156 | +``` |
| 157 | + |
| 158 | +The app will still build now, but the screen will show black. Why is that so? Because we have loaded the |
| 159 | +scene in memory, but we are not showing it yet. To show it, we have to tell the RamsesThread that |
| 160 | +it should create a display and show the scene there. We have to do this once the SurfaceView's surface |
| 161 | +has been created. You guessed it! That belongs in the surfaceCreated() callback: |
| 162 | +
|
| 163 | +```kotlin |
| 164 | +override fun surfaceCreated(holder: SurfaceHolder) { |
| 165 | + // We are working with a thread - we have to catch interrupt exceptions |
| 166 | + try { |
| 167 | + // Creates a display and shows the scene we loaded in onCreate() |
| 168 | + monkeyThread.createDisplayAndShowScene(holder.surface, ClearColor(0F, 0F, 0F, 1F)) |
| 169 | +
|
| 170 | + // Start rendering |
| 171 | + monkeyThread.addRunnableToThreadQueue { |
| 172 | + if (monkeyThread.isDisplayCreated && !monkeyThread.isRendering) { |
| 173 | + monkeyThread.startRendering() |
| 174 | + } |
| 175 | + } |
| 176 | + } catch (e: InterruptedException) { |
| 177 | + Log.e("MainActivity", "surfaceCreated failed: ", e) |
| 178 | + } |
| 179 | +} |
| 180 | +``` |
| 181 | +
|
| 182 | +Multiple things going on here. Let's have a look one by one. |
| 183 | + |
| 184 | +First, we need a try/catch block. Most of the calls to RamsesThread are performed |
| 185 | +asynchronously and therefore can be interrupted. In a real-world application, our business |
| 186 | +logic should handle such interruptions gracefully, e.g. by showing something else or |
| 187 | +displaying a 'loading screen' while reloading the content. In this example, we keep |
| 188 | +things simple and just print the exception stack trace. |
| 189 | + |
| 190 | +Inside the try block, we first create a Ramses display and immediately show the scene we loaded |
| 191 | +earlier in the thread. Then, we tell the thread to start rendering. |
| 192 | + |
| 193 | +Finally, in the catch block we do a trivial error handling - we log the error and proceed. |
| 194 | +Needless to say, |
| 195 | +this is not a true error handling, but we want to keep this example simple. A better way |
| 196 | +would be to tell the |
| 197 | +user of the app that there was a problem with the asset. |
| 198 | + |
| 199 | +Notice how we check if a display is created and if the thread is not already rendering, before we |
| 200 | +ask it to start rendering. The lifecycle-related RamsesThread calls (start/stop rendering, creating displays etc.) are |
| 201 | +not graceful - i.e. they will throw exceptions if you are doing something which is not well defined, |
| 202 | +for example starting to render when the thread is already rendering. |
| 203 | + |
| 204 | +## Improve the lifecycle |
| 205 | + |
| 206 | +If you are an experienced Android developer, you noticed that we didn't implement the other |
| 207 | +two surface callbacks: |
| 208 | +
|
| 209 | +* surfaceChanged |
| 210 | +* surfaceDestroyed |
| 211 | +
|
| 212 | +The surfaceChanged overload should pass the size information down to your scene thread so |
| 213 | +that it can adjust itself |
| 214 | +to the new size (as we do in the [next section](#fix-the-viewport)): |
| 215 | +
|
| 216 | +```kotlin |
| 217 | +override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { |
| 218 | + monkeyThread.resizeDisplay(width, height) |
| 219 | +} |
| 220 | +``` |
| 221 | +
|
| 222 | +The surfaceDestroyed should destroy the display we created: |
| 223 | +
|
| 224 | +```kotlin |
| 225 | +override fun surfaceDestroyed(holder: SurfaceHolder) { |
| 226 | + try { |
| 227 | + monkeyThread.destroyDisplay() |
| 228 | + } catch (e: InterruptedException) { |
| 229 | + Log.e("MainActivity", "surfaceDestroyed failed: ", e) |
| 230 | + } |
| 231 | +} |
| 232 | +``` |
| 233 | +
|
| 234 | +If you start the app now, you should see at least one green monkey! But it doesn't look quite the same |
| 235 | +as the Ramses Composer preview... Let's fix this. |
| 236 | +
|
| 237 | +## Fix the viewport |
| 238 | +
|
| 239 | +Depending on your device/emulator, the monkeys are not centered on the screen, and probably |
| 240 | +look strange in portrait mode. |
| 241 | +This is because the monkey asset doesn't know the size of the display you are showing it on. It uses |
| 242 | +the camera/viewport settings which were statically set in the Ramses Composer! So you have to |
| 243 | +configure these properties in the app. It's time we implement some of the overloaded methods we |
| 244 | +left empty in our MonkeyThread! |
| 245 | +
|
| 246 | +First, we need to obtain and set the properties of the camera used to render the monkeys. |
| 247 | +You can find the camera object in the |
| 248 | +node tree of the Composer: |
| 249 | +
|
| 250 | + |
| 251 | +
|
| 252 | +As you can see, the viewport is fixed at 1440x720 pixels. That's probably not the same as your surface |
| 253 | +size. We have to find these properties in the asset during runtime and set the values to be the same as |
| 254 | +the real pixel dimensions of the surface. |
| 255 | + |
| 256 | +One way to find the camera object is by looking it up by name: |
| 257 | + |
| 258 | +```kotlin |
| 259 | +val cameraRoot = getLogicNodeRootInput("PerspectiveCamera_CameraBinding") |
| 260 | +``` |
| 261 | + |
| 262 | +The name is a combination of the name in the RamsesComposer project ('PerspectiveCamera' in |
| 263 | +this case) and the binding |
| 264 | +used to control its properties (a 'CameraBinding' in this case). For more information on the |
| 265 | +available objects exported by |
| 266 | +the Ramses Composer, see [this page](../data_and_scopes/README.md). |
| 267 | + |
| 268 | +You can also find a list of all exported objects and their names in the export menu of the |
| 269 | +Ramses Composer (File -> Export). |
| 270 | + |
| 271 | +Next, we want to store the properties of the camera viewport in our MonkeyThread so that we |
| 272 | +can set them to their correct value later: |
| 273 | + |
| 274 | +```kotlin |
| 275 | +private var viewportWidth: Property? = null |
| 276 | +private var viewportHeight: Property? = null |
| 277 | +private var aspectRatio: Property? = null |
| 278 | + |
| 279 | +override fun onSceneLoaded() { |
| 280 | + val cameraRoot = getLogicNodeRootInput("PerspectiveCamera_CameraBinding") |
| 281 | + |
| 282 | + viewportWidth = cameraRoot?.getChild("viewport")?.getChild("width") |
| 283 | + viewportHeight = cameraRoot?.getChild("viewport")?.getChild("height") |
| 284 | + aspectRatio = cameraRoot?.getChild("frustum")?.getChild("aspectRatio") |
| 285 | +} |
| 286 | +``` |
| 287 | + |
| 288 | +Each logic object in the scene has nested list of properties, which you can obtain either by |
| 289 | +iterating with getChildCount()/getChild(int) or by querying by their name, as we do in the snippet above. |
| 290 | + |
| 291 | +But how do we know the names of these properties? In this example, the object we query is a |
| 292 | +CameraBinding - one of |
| 293 | +the standard objects provided by `Ramses Logic` to interact with a Ramses scene. As such, the object has |
| 294 | +a fixed list of properties, which are documented in the |
| 295 | +[CameraBinding documentation](https://ramses-logic.readthedocs.io/en/latest/classes/RamsesCameraBinding.html#classrlogic_1_1RamsesCameraBinding). |
| 296 | +You can find a list of all such binding objects in the |
| 297 | +[binding documentation](https://ramses-logic.readthedocs.io/en/latest/classes/RamsesBinding.html) - |
| 298 | +it lists all the subclass which bind Ramses objects. |
| 299 | + |
| 300 | +Now, we have a handle of the camera properties. All that remains to be done is to set their |
| 301 | +values based on the |
| 302 | +surface size and orientation. We do this in the onDisplayResize() callback of the MonkeyThread: |
| 303 | + |
| 304 | +```kotlin |
| 305 | +override fun onDisplayResize(width: Int, height: Int) { |
| 306 | + viewportWidth?.set(width) |
| 307 | + viewportHeight?.set(height) |
| 308 | + aspectRatio?.set(width.toFloat() / height) |
| 309 | +} |
| 310 | +``` |
| 311 | + |
| 312 | +Voila! The monkeys will now look undistorted and centered on the screen, regardless of |
| 313 | +its size and orientation. |
| 314 | + |
| 315 | +## Doing more interesting things |
| 316 | + |
| 317 | +What we did in the previous sections is configure the monkey asset to look correctly based on screen |
| 318 | +size. We can make the app a bit more interesting by using some of the features we implemented in the |
| 319 | +monkeys tutorial - for example controlling the lights. We can control any property of the scene either |
| 320 | +per frame, or when specific events occur (touch gestures, sensors etc.). For example, we can switch the |
| 321 | +light direction every two seconds like this: |
| 322 | + |
| 323 | +```kotlin |
| 324 | +private var lightId: Property? = null |
| 325 | +private var frame = 0 |
| 326 | + |
| 327 | + |
| 328 | +override fun onSceneLoaded() { |
| 329 | + // ... |
| 330 | + |
| 331 | + lightId = getLogicNodeRootInput("LightControl")?.getChild("light_id") |
| 332 | +} |
| 333 | + |
| 334 | +override fun onUpdate() { |
| 335 | + lightId?.set((frame / 60) % 3) |
| 336 | + frame += 1 |
| 337 | +} |
| 338 | +``` |
| 339 | + |
| 340 | +We simply count the frames, and every 60th frame we toggle the light id by one (and restart |
| 341 | +from 0 when we reach 3). |
| 342 | +For a more complex example with touch gestures, please see |
| 343 | +[a more sophisticated example](#see-a-more-complex-app-example). |
| 344 | + |
| 345 | +## See a more complex app example |
| 346 | + |
| 347 | +This tutorial is designed to be small in code and relatively simple. For a more complex example, |
| 348 | +both in terms of |
| 349 | +graphics and in terms of control, please check out the |
| 350 | +[Digital car app](https://github.com/bmwcarit/ramses-sample-app). |
0 commit comments