As I’ve been picking up experience with various different 3D rendering and Augmented Reality (AR) frameworks available on mobile (Android and iOS), I’ve often asked, what is available that I can I use for both? I love RealityKit on iOS, and recent developments that have made it seamless to integrate with SwiftUI, as it has enabled my rapid development of Juego de la Rana and ER3D. Of course, these are iOS-only. On the other hand, Android has ARCore and various of its own utilities, which, admittedly, I still have not dived into much as I hope to eventually. Unity provides a great multiplatform option, but each Unity project tends to be gigabytes in size, which is not desirable for quick prototyping.
Over the past year, I’ve come to be aware of the Three.js JavaScript utility, which is free and open source, and when stripped down to its core, less than one megabyte in size. “Three” is one of the core libraries leveraged by That Open Company, who I have been following on LinkedIn, and provide open source Building Information Modeling (BIM) software, services, and classes. It is primarily a web development utility, not frequently (or ever?) used on mobile. However, because a client I was working with had started to use it for a website they are developing, I started to ask the question, can I take some of that same code, and package it into a native mobile app?

Of course, and as you can see from my LinkedIn post, the answer is a (mostly) resounding yes! In fact, I used it to create a rapid prototype of our product, which we can hand off to web designers to flesh out the finished product, while reusing as much code as possible. Since I didn’t find any other posts in my Google search from anybody else who had done this, I thought I would write up my own tutorial. The code itself may be found on my GitHub, and you can follow the procedure for how I created it in the section below. At the conclusion, the app will resemble the rotating cube in the graphic above, which looks simple, but make note:
- The 3D model in the center is created in a WebView from HTML and Javascript.
- The slider that scales the model sends commands from the native app to Javascript.
- The text that is printed at the bottom is generated in a Javascript callback.
I hope you’ll follow along with the procedure below, and can find value applying it to your own projects.
Procedure
The sections below provide a step-by-step process describing how a lightweight, Three.js environment may be embedded in an Android/iOS multiplatform app using Jetpack Compose Multiplatform. The tutorial is detailed, however, you may also choose to simply clone or fork this repository to start your own project. Alternately, you can build your project starting from the Kotlin Multiplatform (KMP) wizard, and get Three.js running as follows:
- Include a dependency in your
build.gradle.ktsfile for Compose Webview Multiplatform. - Add an HTML header file with an import map for Three.js version 0.173.0, and preferred styling for mobile, into
composeResources/files. - Add a sample JavaScript file, which renders a simple rotating cube, into
composeResources/files. - Replace code in the common
App.ktcode file with code that generates a WebView referencing the HTML and Javascript resources.
I mostly bypass some of the basics of Android and Jetpack Compose, but have provided links where appropriate. The one thing I do suggest is to use the Kotlin Multiplatform Wizard that I have linked. While you can create the project directly within Android Studio while bypassing that link, generally the wizard is agreed to be more up-to-date at any given time, and its project structure should be consistent with the tutorial.
Coding Environment Setup
As I’ve mentioned, I’m not going to cover all of the basics, as I will assume if you have reached this point that you already have experience in installing Android Studio (Link) and configuring it for Kotlin Multiplatform (Link). If you are also planning to build for iOS, make sure to install XCode as well, which you can download from the App Store. I do recommend you create a new GitHub repository to track your work, because, … always.

Compose Multiplatform Template
To start your KMP project, I recommend you use the official wizard provided by Jetbrains. This is not a requirement, you can also do a simple “New -> Project” in Android Studio and choose a KMP template, however, I’ve found that the online wizard tends to be a more up-to-date, and I’ve had issues with initial builds failing when starting from inside Android Studio.

In the wizard, you will provide a name, which can but does not have to match what you named your Github repository; I chose ThreeDemo. Next you will provide a unique project ID, where I chose com.dcengineer.threedemo. These will generally use reverse domain syntax, and if you publish your app, the same will be used by the Google Play or App Store to identify it. You should choose carefully as this may become semi-permanent, as-in, once you submit a build to either store, they will not let you change the project ID, even though you can change the app’s name.
Also, you will select which operating systems you want to support, choosing from Android, iOS, desktop, web, and server. For demonstration, I only selected Android and iOS. For iOS, I selected to share UI, which is the whole purpose of Compose Multiplatform. It is possible to migrate back to SwiftUI at a later date, however, Compose is a good choice for early prototyping. Desktop, web, and server options are available, but will have varying degrees of support, and may complicate your prototyping if you are specifically targeting mobile.
Click the Download button when you are ready, and a zip file will be dropped into your Downloads folder. You should unzip this file and move into your project folder (which you did create as a git repository, right?). If this project folder is under git revision control, you can check it in using the commands below (replacing ThreeDemo with whatever name you chose).
git add ThreeDemo git commit -am "added the compose multiplatform template files" git push
Open the Project in Android Studio
Start Android Studio, and click File -> Open, then select the folder you just copied over. In my case, I am selecting the ThreeDemo folder. You do not need to select the root directory of the GitHub folder, just the folder created by the wizard. There are a couple of warning dialogs that will pop up; usually, I select to open the project in a new window, and then I “Trust” the project (its mine, after all). Android Studio will open starting from the automatically generated README.md file.




After various gradle build and sync tasks complete, you may “Run” the project. The template app is just a simple button, which will display a Compose Multiplatform logo and platform-specific greeting when tapped.

Optional: Remove Unnecessary Code and Resources
The KMP template provides a good introduction and a working multiplatform app, however, it also comes with a bit of clutter (extra files and code) that you probably don’t want or need to carry forward into your project. This selection will cover deleting that clutter, which I think is a good idea, but you can also choose to move onto the next section on creating the WebView, if you prefer.






In the upper left of the Android Studio window, there is a dropdown; if it shows the word “Android,” click on it and instead select “Project,” which is my preferred view for multiplatform dependencies. Highlight the following files, and delete them (right click and refactor, press delete key, or other):
composeApp/src/androidMain/kotlin/<yourAppID>/Platform.android.kt- A script to get the string name of the Android platform,
Android 34in the animation above.
- A script to get the string name of the Android platform,
composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml- A Compose Multiplatform logo, the image in the center of the animation above.
composeApp/src/commonMain/<yourAppID>/Greeting.kt- A script that generates the greeting text,
Compose: Hello, Android 34in the animation above.
- A script that generates the greeting text,
composeApp/src/commonMain/<yourAppID>/Platform.kt- The “expect” function, that directs to the respective
Platform.android.ktorPlatform.ios.ktfiles depending on what device you are using.
- The “expect” function, that directs to the respective
composeApp/src/iosMain/kotlin/<yourAppID>/Platform.ios.kt- A script to get the string name of the iOS platform.
If you get a warning that the Greeting.kt file has one usage, delete it anyway, as we will remove that usage in the following step.
Next, navigate to compose/src/commonMain/<yourAppID>/App.kt. Notice that the Greeting().greet() item is highlighted red, indicating that the app will not build due to the deleted dependency. Delete all of the code Between MaterialTheme { ... and its closing bracket, and replace with a single Text("Three.js will go here!"). This will correct the red error, meaning your app can now build, but we can still declutter a bit more.
Hover above one of the now “grayed-out” import statements at the top of the window, and click “optimize imports” to remove those that are no longer required. After optimizing imports, the entire App.kt file should be as follows (replacing your app ID with whatever you provided as a Project ID in the Wizard):
package <yourAppID>
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
@Preview
fun App() {
MaterialTheme {
Text("Three.js will go here!")
}
}
Run the app again, to get the now-simplified app.
Add the Compose Multiplatform Webview Dependency
As of February 2025, there is no official Web View built-in to the Compose Multiplatform framework, however, we can use a third-party widget created by Kevinn Zou Link. We will add the dependency to our project using Gradle, specifically in the file composeApp/build.gradle.kts Link. Scroll down to the section kotlin { ... sourceSets { ... commonMain.dependences { ... and add:
api("io.github.kevinnzou:compose-webview-multiplatform:1.9.40").


Optionally, you may hover over the new dependency and click “replace with new library catalog dependency …”. This will have no effect on the app itself, but is a bit cleaner for dependency management. Selecting this option automatically changes the line above api(libs.compose.webview.multiplatform). The corresponding module location and version numbers will be moved to gradle/libs.versions.toml, which you can review on your own.
You may now click “Sync Now” at the top of the screen to rebuild the gradle project. This will not rebuild or change the app itself, but will trigger gradle to download and build the dependencies.
Navigate back to compose/src/commonMain/<yourAppID>/App.kt. Create a basic HTML string to test the WebView in the space directly above the MaterialTheme { ... line:
val html = """
<html>
<body>
<h1>Three.js will go here!</h1>
<p>Soon with JavaScript...</p>
</body>
</html>
""".trimIndent()
Immediately below, create the state that holds the HTML string:
val webViewState = rememberWebViewStateWithHTMLData(
data = html
)
Replace the Text("Three.js will go here!") line from earlier with WebView(webViewState), and remove the Text import. Run the app to verify that the HTML is loaded and formatted in the WebView.

Create HTML Header and Three.js Javascript Resources
Next we will create a pair of shared resource files; shared meaning the same files get loaded whether you are running Android or iOS, and resources meaning they are part of the compiled build, or “permanent” data files that are stored in the app binary. These get loaded as raw bytes, and then decoded to strings which can be provided to the WebView in the same manner as the HTML string we created above.





If it does not exist already, create a composeApp/commonMain/composeResources/files directory. Note, when we deleted the Compose Multiplatform logo earlier, it was in composeResources/drawable. However, because there are no other shared resources, Android Studio may have automatically removed the composeResources folder. If so,, we must add it back in.
Right click on commonMain in the Projects navigator, hover over “New” and then “Directory,” and enter composeResources in the dialog that appears. Repeat this process, right clicking on the newly created composeResources and creating a files subdirectory. Add empty files named index.html and cube.js inside.
Inside the newly created index.html, paste the following:
<!DOCTYPE html>
<head>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.173.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.173.0/examples/jsm/"
}
}
</script>
<style>
* {
margin: 0;
}
</style>
</head>
<body>
<script type="module">
/*CODE*/
</script>
</body>
</html>
This HTML file defines the paths and version numbers for the Three.js packages that we will import inside the subsequent JavaScript. Technically, you could do this in the Javascript itself, but I’m opting to shift some of that versioning responsibility over to the HTML. Also, the style designates that the Three.js view should have zero margins on its edges. I added this because I found that the default builds would place a few pixels of white space around the edges, and there was a bit of weird scrolling behavior on touch, which this detail seemed to alleviate. Finally, there is a placeholder for a module script, with the commented /*CODE*/ text that we will replace with our Javascript. Technically, all of this could be loaded into one file, but again, I am separating responsibilities.
Inside the newly created cube.js, paste the following
import * as THREE from "three";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshNormalMaterial();
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
window.cube = cube;
camera.position.z = 5;
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
const { x, y, z, w } = cube.quaternion;
const quaternionString = `Quaternion: (x: ${x.toFixed(2)}, y: ${y.toFixed(2)}, z: ${z.toFixed(2)}, w: ${w.toFixed(2)})`;
window.kmpJsBridge.callNative("Quaternion", quaternionString, null);
}
animate();
We can see at the top of the script lies the import of three, which uses a simplified syntax (shorter path) because we defined the import map in the HTML header. Next, scene, camera, and render elements are created, and sized to fill the provided window. Next we create a simple cube, which I provide a MeshNormalMaterial because it gives a bit more visual resolution to the cube as it moves, and as it happens to be a key component to my client’s project that I referenced at the start. Finally, I create an animate loop, which rotates the cube slightly, and re-renders the scene, at each animation frame.
There are a pair of items that are also included in the Javascript, which would not be present in a “standard” web-based Three.js project, but have a key role in providing bi-directional communication between the native Kotlin code, and Javascript:
window.cube = cube;makes the cube available for modification by the WebView.window.kmpJsBridge.callNative("Quaternion", quaternionString, null);sends a message through a native Javascript-to-Kotlin bridge containing a string.
We will write Kotlin code that utilizes these details in the final procedure section on bi-drectional interactions.
Add Internet Permission (Android-only, but Important)
Recall that earlier, we defined an import map as part of the HTML header definition, which defined URLs from which to download the Three.js dependencies. Well, on Android, you will only be able to download said dependencies if you have set up your app to have internet access, otherwise, the user will only see a blank screen. The internet permission may be added to the composeApp/src/androidMain/AndroidManifest.xml file, by including the line:
<uses-permission android:name="android.permission.INTERNET" />

Update the WebView to Reference the Resources
Now that we have created the HTML and Javascript files, we need to update our Kotlin code to read from these files, and create new text strings that are provided to the WebView, replacing the simple HTML string we defined in-line earlier. There is a slight complication here because the resource API runs asynchronously, presenting an issue that may be confusing to some developers. Thus, I am including some additional steps here to demonstrate that complication, and how to fix it. However, if you are already aware of the resources API, you may skip to where I read the string inside of a LaunchedEffect.
First, try replacing the existing HTML string with a version that will read from the shared resource file. Because we used the wizard earlier, the dependencies were already added.
val html = Res.readBytes("files/index.html").decodeToString()
The .readBytes method will have a red underline, indicating errors we must correct prior to compilation:
- “Suspend function ‘readBytes’ should be called only from a coroutine or another suspend function”
- “This API is experimental and likely to change in the future

The latter is a simple fix, click the “opt-in” link at the bottom, which will insert @OptIn(ExperimentalResourceApi::class) above your App definition. The former may be corrected by reading the file within a LaunchedEffect; since we also will want to load the text of the cube.js file, we do this with both:
var html by remember { mutableStateOf("") }
var js by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
html = Res.readBytes("files/index.html").decodeToString()
js = Res.readBytes("files/cube.js").decodeToString()
}
We have designated the HTML and JavaScript strings as remember variables so that their state is retained across re-compositions, and to assure that the UI updates once their text has been loaded. These are initially created as empty strings, because with the readBytes method being asynchronous, we cannot obtain the string representation of the files at the time of their creation. We must wait for these values to be populated inside of the asynchronous LaunchedEffect, which, realistically probably happens in the blink of an eye. However, if these were very large files, that prevents their loading from blocking the UI.
Next, move the content associated with the WebView into its own @Composable function, which takes the html string:
@Composable
fun ThreeJsWebView(html: String) {
val webViewState = rememberWebViewStateWithHTMLData(
data = html
)
WebView(webViewState, modifier = Modifier.fillMaxSize())
}
By separating this WebView into a separate function that takes a stateless string as its input, this will trigger Compose to draw an update when the strings change in the higher-level App. Also, in general I think its good practice to move code outside of the App to try to keep the App itself from containing excessive logic.
Call this new ThreeJsWebView(html) function in place of the prior WebView(webViewState), and insert the JavaScript in place of the placeholder /*CODE*/:
MaterialTheme {
ThreeJsWebView(html.replace("/*CODE*/", js))
}
If you build and run the app now, you should see a rotating cube; congratulations, you have embedded Three.js in a Compose Multiplatform app!


Building for iOS
Of course, with KMP and Compose in particular being designed to quickly prototype apps that run on both Android and iOS (and desktop and web, but I think its still mostly mobile), now is a good time to show that the same code will build to iOS with minimal effort. The main change with iOS is that Apple will enforce that you sign your app, even when prototyping, as a security measure. This requires a developer account.


Open XCode, and select “Open Existing Project,” then select the project at iosApp/iosApp.xcodeproj.When the project opens, select the projectNavigator tab (the blue folder icon), and iosApp in the upper left corner. Click on Signing & Capabilities in the center of the screen, and select your team, which will be associated with your developer account.
Select either a simulator, or your physical device from the list at the top. Click the play button on top to build and run the app. Congratulations again, you have now built a clone of the Android app that runs on iOS, with the exact same Three.js animation!
Some Drawbacks on iOS
I should add a bit of commentary here, however, to say it is not all sunshine and rainbows when using KMP, or really any other multiplatform framework to build an iOS. Jetpack Compose was designed as a first-class native framework for Android, and is the officially recommended framework by Google for that purpose. KMP support came afterwards, and iOS support is still considered to be in beta, albeit, a very mature phase of beta.
So what does that mean about our tutorial here today? Well, after initially getting a very pretty cube as you can see in the screenshot above, on subsequent builds I got a blank screen. Even though, as far as the code itself, very little if anything had changed. As I write this, I suspect that this is a build issue rather than a code issue; the debug logs suggest that Three.js did indeed load, and the animation loop is running, it is just not being rendered to the screen. As I write this, I still have not diagnosed the cause, but have posted an issue to the development team to see if anyone knows.
Bottom line: if this were a real project, and I was primarily targeting iOS, I would use the iOS native tools (Swift, SwiftUI, RealityKit, etc). But this is a tutorial to show that adding Three.js in a KMP WebView **is** possible, even though it might inflict a bit of pain along the way. Proceed with caution.
Update, 28 February 2025 – Corrected Blank Screen
I did uncover the culprit for the blank screen, and in subsequent builds, the issue with the blank WebView on iOS has been fixed. This is a bit of an obscure thing, but seems to be somewhat commonly encountered based on reviewing closed issues on the Compose Webview Multiplatform Github page.
If you’ve read to this point, you would have seen the .fillMaxSize() modifier applied to the WebView:
WebView(webViewState, modifier = Modifier.fillMaxSize())
What had happened was, in later sections (I’m going out of chronological order now, the “error” I introduced actually came later in this tutorial), I had wrapped that WebView inside of a Column, and replaced the modifier with:
WebView(webViewState, modifier = Modifier.weight(1f))
The Column is there because I wanted to add some controls and text that would lie outside of the WebView, and under normal circumstances, that .weight(1f) modifier is a proper approach to tell our view to fill up as much space as is available without infringing on the space of other members of the column. On the other hand, the WebView would overflow its space allocation and would push the other members of the Column offscreen if I used .fillMaxSize() alone. So, on Android at least, a simple .weight(1f) modifier did the job exactly as intended.
However, it appears that on iOS, when you take the .fillMaxSize() modifier away, the WebView interprets this as if it has been provided zero space. So what is the correction? Use both:
WebView(webViewState, modifier = Modifier.weight(1f).fillMaxSize())
Again, for most Compose views, the .weight modifier should be sufficient, and my preference would be to only use that one as the .fillMaxSize() is mostly redundant. However, in the case of the WebView, it does hold a unique purpose in assuring that space gets properly utilized. I’ve made the subsequent updates in sections to follow, and the iOS builds are as-intended. Carry-on ;).
Adding Bi-Directional Interactions
Aside from simply rendering pretty 3D pictures, a key attribute of any app is to provide for user interactions. Furthermore, the user should be able to both “send” interactions, and “receive” a response, which is what I mean by the term “bi-directional.” In code terms, when I refer to the user, what I’m really speaking of is the native Kotlin code, where its interactions must be sent to the Javascript that hosts the Three.js view. This requires making use of a “navigator” and a “bridge” for the respective send and receive actions, both of which are provided by the Compose Multiplatform WebView widget.
Sending User Input to Javascript
Javascript commands can be evaluated after loading the WebView with a WebViewNavigator object, which we “remember,” meaning the same navigator should be retained across recomposition cycles of the UI. Inside our ThreeJsWebView add the following code, which creates a navigator that can evaluate JavaScript commands or other common browser operations, and a float value that we will control:
val navigator = rememberWebViewNavigator()
var scale by remember { mutableStateOf(1f) }
Provide the newly created navigator to the WebView, which will now be created inside of a Column as follows:
Column { // Create a column, as we want to place a control underneath
WebView(
state = webViewState,
// Note addition of .weight(1f) modifier in addition of .fillMaxSize().
// Normally, the .weight alone is sufficient to allocate space, however
// on iOS we need to retain .fillMaxSize() to assure the WebView expands
// to fill that allocation; not the same on Android.
modifier = Modifier.weight(1f).fillMaxSize(),
navigator = navigator
)
}
Create a Slider control inside the column and directly underneath the WebView:
Slider(
value = scale,
onValueChange = {
scale = it
navigator.evaluateJavaScript("cube.scale.set($scale, $scale, $scale);")
},
modifier = Modifier.padding(12.dp),
valueRange = 0.1f..2f
)
Run the app, and you will see the new slider control, which will scale the size of the cube when you toggle its value.

Receiving Updates from Javascript
To receive callbacks from JavaScript to the Kotlin app, we create a WebViewJsBridge. This also requires that we also create a custom message handler class that conforms to IJsMessageHandler. This will receive a string from the JavaScript code representing the cube’s orientation:
class QuaternionMessageHandler(val handler: (String) -> Unit): IJsMessageHandler {
override fun handle(
message: JsMessage,
navigator: WebViewNavigator?,
callback: (String) -> Unit
) {
handler(message.params)
}
override fun methodName(): String {
return "Quaternion"
}
}
Create a WebViewJsBridge inside the ThreeJsWebView, underneath where we created the navigator and scale. We will create a remembered string that will be the text representation of the quaternion, or orientation of the cube at any instance of time, and register an instantiation of the message handler. Note that each time a message is received by this handler, it will perform an update to the quaternion.
val bridge = rememberWebViewJsBridge()
var quaternion by remember { mutableStateOf("") }
bridge.register(QuaternionMessageHandler(handler = { quaternion = it }))

Provide this bridge as an argument to the WebView:
WebView(
state = webViewState,
modifier = Modifier.weight(1f).fillMaxSize(),
navigator = navigator,
webViewJsBridge = bridge
)
Also, add a Text underneath the Slider to display the current quaternion string:
Text(quaternion, modifier = Modifier.padding(12.dp))
Recall that earlier, when we created the cube.js file, it included the following callback code inside the animate() function:
const { x, y, z, w } = cube.quaternion;
const quaternionString = `Quaternion: (x: ${x.toFixed(2)}, y: ${y.toFixed(2)}, z: ${z.toFixed(2)}, w: ${w.toFixed(2)})`;
window.kmpJsBridge.callNative("Quaternion", quaternionString, null);
We can see that at each animation step, that quaternion string is being generated in Javascript, and that we use a bridge method to call a corresponding native handler. The first argument is the “name” of that handler, which corresponds to its methodName. The second argument is a string, which the handler will interpret in its message.param, which gets flowed up into our user interface, and displayed on the bottom of the screen as a Text composable.
Now if you run the app, you will see text at the bottom of the screen containing the instantaneous orientation of the cube, which was provided to the Kotlin Compose Multiplatform UI from inside a callback in JavaScript, using Three.js toolsets!
I added a header text as well, just so people know what they are seeing, here is the Android version at completion, and animated:

Summary
Three.js is a popular and lightweight Javascript toolset that is used in web development to produce visually appealing renders of 3D objects directly inside your browser. With a bit of effort, it can also be embedded inside of a WebView in a native mobile application using KMP and Jetpack Compose, and then built to run on both Android and iOS. You can even add bi-directional interactivity so that you can add native user interface elements on top of the Three.js view, and send and receive messages between Kotlin and Javascript.
This provides a viable option for someone who may be familiar with Three.js from a web development background, who wants to extend some of their existing code into the native mobile domain. In my case, we had several Javascript functions previously developed for a former client, and we wanted to create a rapid prototype that called those functions on a mobile device. This may well be a common use case, for developers who want to feature a subset of their website code inside of a mobile app, or vice-versa.
Of course, with Jetpack Compose being a standard component of the Android stack, support on Android is expected to be much better than on iOS, as noted by the challenges I encountered with a blank screen on iOS after the initial build. I learned the reason, which is that I needed to retain a .fillMaxSize() modifier after enclosing the WebView in a Column, however, this is atypical behavior on iOS. This points to an inherent reality, which is, if I really want to target iOS, its usually better to use its own native tooling. But, with this being a tutorial rather than a real project, multiplatform takes center stage.
Altogether, I hope this will provide real-world utility to someone who is hoping to bridge between the web and native mobile domains. In my own searches on Google, I did not find any other tutorials that covered this exact use case, so perhaps I’ve come up with something unique!
If you want to collaborate with me on a project utilizing native mobile, Three.js, or mechanical engineering skillsets, feel free to add me on LinkedIn or shoot me an email at Eliott dot Radcliffe at DC-Engineer.com.


Pingback: Updates for the Month of March – DC Engineer