Creating 3D models in Spline for Three.js
Table of contents
If you've been online lately, you've probably heard of Three.js, a web framework for 3D graphics. What you can do with it is absolutely stunning. However, if you want to display any complicated models which go beyond what a cube or a sphere can do, it's best to create the model in a graphics application and then import it in Three.js. That's where Spline comes in.
People have already built incredible things with Three.js. Here are a few examples: My room in 3D by Bruno Simons
Atmos by Leeroy
Joshua's world
What is Spline?
Spline is a new online-based 3D software that allows just about any designer to create with intuitive tools. It doesn't overwhelm you with menus and tools, it has just the basics. It has both a free and a paid version, but for most, the free tier does enough.
Creating a 3D model in Spline
To show what can be done, I decided to make a simple model: a "Roundel" – a circular disc with my logo which I'm using as part of my website identity.
Using Spline
To start off, create an account in Spline and open a new project. It will show you an window with a rectangle and a light, an empty slate if you will.
If you've already used a 3D editor before, this should feel like child's play. Otherwise, it's a bit like using a 2D graphics editor like Figma or Sketch, except that everything is now in 3D.
There are four main sections to the editor:
- On the left, the items list shows the objects you have in your 3D scene.
- On the top center, a toolbar has options for adding shapes and lights, exporting, and previewing.
- On the right, the edit panel allows you to change attributes of the selected object.
Most of the commands are intuitive and explained by the onboarding process, but here are the most important ones:
- Alt + Drag – pan around the 3D scene
- Scroll – Shift around the 3D scene
- Alt + Scroll – Shift around on one axis only
Adding shapes
In order to make my roundel, I started off by adding a circle. This is done by selecting "+" on the toolbar and selecting ellipse (O). Click and drag in the scene to place the circle. In order to make the ellipse circular, hold shift while dragging to constrain proportions.
We now have a 2D circle. To make it have volume and convert it to a cylinder, we can "extrude" it: Select the circle and add a value to the "extrusion" slider, under the shape menu. It might not look like we've changed much at first, but if you Alt+Drag, you'll see that the circle now has a volume.
I found the corners a little too sharp for my liking, so I decided to add a bevel to the cylinder's vertices. This can be done by dragging the "Bevel" slider right under the extrusion slider.
Next, it's time to add some text. This is delightfully easy in Spline, and I wish other 3D editors had the same ease-of-use. Select the "T" icon in the toolbar, and click on one of the surfaces of the cylinder. Each surface should highlight in red as you hover over it, indicating that the text will snap to it.
Type your text and don't worry if you don't see anything – Every object you add to the scene has a default dull gray color – we'll tweak the colors next!
Materials and colors
Select the ellipse and add the color you want in the "Material" section of the object editor. I decided to go with my site's pastel orange. I colored the text in black in the same way.
I wanted to change the font to reflect my site's wordmark. This was extremely easy to do: select the text, go to the "font" menu. You can even upload a custom font!
Next, to make everything 3D, I also extruded the text.
Lighting
The last part is lighting. In the physical world, multiple cues exist to give us information about an object's physical location: The stereoscopic information from our eyes, the texture of the object and the shadows it casts. Since we don't get stereoscopic information on screens, we need to rely more heavily on other cues such as the reflectivity of the object and the shadows it casts. That's where different lights come in play.
I won't dive into the specifics of each light as it's beyond the scope of this article, but you can play around with the different lights and see which one looks best. Most of the time I use a point light which is a single source of omnidirectional light. I like to position it so that it casts nice shadows – this is why I extruded the "Felix" lettering. When you're in the editor, lights are represented by wireframes. Below you can see my model with a light in the top left corner casting shadows from my lettering.
Exporting
There are multiple formats you can export your 3D file in, some are free and others aren't (for instance, you can export in formats suited for 3D printing or augmented reality). The easiest way for displaying the 3D model on the web is to use the embedded viewer or the code export, which provides you with code to import the model and display it in your web application.
However, I try to be careful about the technology I use, so I don't get locked up in a proprietary "ecosystem", and as Spline is closed-source, I feel more comfortable exporting the model to an open-source file format, even though it does complicate things a bit. To combat this, I'll show you how you can do this in a React/Node.js project for free. After much trial and error, here's the workflow I came up with:
Export the model as GLTF or GLB (It's best to use GLB in production as it's a compressed version of GLTF, GLTF being just JSON). It's one of the free exports and is supported by Three.js. Save that to the public directory of your web project. The issue with spline exports to GLTF/GLB is that it doesn't retain lighting or materials, only geometries are exported, however there is a workaround for that.
I use Next.js for my projects, this is what I found for that but a lot of it applies to other frameworks!
In order to see the different export options, click the "Exports" button on the middle toolbar.
If you go back to the "Exports" panel of your spline file and select the "Code export" tab, Spline automatically generates the positioning as well as the lighting in Three.js format. You can select which flavor of Three.js you use (I use react-three-fiber
), and copy the code. For instance, here's the code for my model:
/* Auto-generated by Spline */ import useSpline from '@splinetool/r3f-spline' import { PerspectiveCamera } from '@react-three/drei' export default function Scene({ ...props }) { const { nodes, materials } = useSpline('https://[external spline URL]') return ( <> <color attach="background" args={['#74757a']} ></color> <group {...props} dispose={null}> <pointLight name="Point Light" castShadow intensity={0.82} distance={1379} shadow-mapSize-width={1024} shadow-mapSize-height={1024} shadow-camera-near={100} shadow-camera-far={100000} position={[-254.65, 150.88, -135.21]} ></pointLight> <group name="Group"> <mesh name="Text" geometry={nodes.Text.geometry} material={materials['Text Material']} castShadow receiveShadow position={[-46.56, -1.88, -13.23]} rotation={[0, -Math.PI / 2, 0]} ></mesh> <mesh name="Ellipse" geometry={nodes.Ellipse.geometry} material={materials['Ellipse Material']} castShadow receiveShadow position={[-8.73, 0, -25.43]} rotation={[0, -Math.PI / 2, 0]} scale={1} ></mesh> </group> <PerspectiveCamera name="1" makeDefault={true} far={100000} near={70} fov={45} position={[-949.88, 139.05, 279.99]} rotation={[-0.46, -1.25, -0.44]} scale={1} ></PerspectiveCamera> <hemisphereLight name="Default Ambient Light" intensity={0.75} color="#eaeaea" ></hemisphereLight> </group> </> ) }
Displaying in React
Add the code provided from your Spline file as a new component (make sure to add the code provided by your project and not the one above as it's project-specific!). You'll notice that in the code there's an external link to your project hosted on Spline servers, but don't worry about that, we'll get to it.
Installing packages
Next, you'll want to install the following packages to your project using npm
or yarn
:
@react-three/fiber
three
@react-three/drei
We're going to alter the code provided by Spline to import the self-hosted GLTF file. Replace the line with useSpline with the following code:
//Replace '/felix_roundel.glb' with the path to your gltf/glb file const gltf = useLoader(GLTFLoader, '/felix_roundel.glb', loader => { const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath('/draco/gltf/'); loader.setDRACOLoader(dracoLoader); }) const { nodes, materials } = gltf;
This uses a "loader" which is a function that loads your 3D file. We use DRACOLoader as well, it's an optimization library for importing point clouds and meshes.
Configuring loaders
Next, you'll want to head to the following path of your project: /node_modules/three/examples
, and copy the draco
file to the public folder of your project. This is one of the quirks of the dracoLoader: It not only needs a library import, but it needs to import a decoder using the setDecoderPath
method (see https://stackoverflow.com/questions/56071764/how-to-use-dracoloader-with-gltfloader-in-reactjs). You can supply it with a local decoder hosted in your project's public folder as I describe above, or alternatively you can link it to an externally-hosted library.
Remove the default export in the Scene function, and wrap it around a <Canvas>
element. This is needed to prevent errors with paths (see https://stackoverflow.com/questions/75950437/nextjs-react-three-drei-typeerror-failed-to-parse-url). I know, there's a lot of quirks to navigate around, but it'll be worth it in the end! This is what your code should look like for now:
import { Canvas } from "@react-three/fiber"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader"; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader' import { PerspectiveCamera } from '@react-three/drei' import { useLoader } from "@react-three/fiber"; export function Object(){ const gltf = useLoader(GLTFLoader, '/felix_roundel.glb', loader => { const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath('/draco/gltf/'); loader.setDRACOLoader(dracoLoader); }) const { nodes, materials } = gltf; return ( <> <color attach="background" args={['#74757a']} ></color> <group dispose={null}> <pointLight name="Point Light" castShadow intensity={0.82} distance={1379} shadow-mapSize-width={1024} shadow-mapSize-height={1024} shadow-camera-near={100} shadow-camera-far={100000} position={[-254.65, 150.88, -135.21]} ></pointLight> <group name="Group"> <mesh name="Text" geometry={nodes.Text.geometry} material={materials['Text Material']} castShadow receiveShadow position={[-46.56, -1.88, -13.23]} rotation={[0, -Math.PI / 2, 0]} ></mesh> <mesh name="Ellipse" geometry={nodes.Ellipse.geometry} material={materials['Ellipse Material']} castShadow receiveShadow position={[-8.73, 0, -25.43]} rotation={[0, -Math.PI / 2, 0]} scale={1} ></mesh> </group> <PerspectiveCamera name="1" makeDefault={true} far={100000} near={70} fov={45} position={[-949.88, 139.05, 279.99]} rotation={[-0.46, -1.25, -0.44]} scale={1} ></PerspectiveCamera> <hemisphereLight name="Default Ambient Light" intensity={0.75} color="#eaeaea" ></hemisphereLight> </group> </> ) } export default function Scene(){ return ( <Canvas> <Object></Object> </Canvas> ) }
Materials
If you run the code, you'll notice that everything looks a bit sad and gray. This is because react-three-fiber can't find the materials in the GLTF/GLB file (exporting textures is a premium feature). We'll add our own materials for the meshes. There are different materials available in Three.js, the most useful one I find is MeshPhongMaterial
. It's realistic and allows for shininess and shadows, and responds well to different types of lights. This is how you can define a material:
const material = new THREE.MeshPhongMaterial({ color: 0x000000, transparent: false, opacity: 0.5, specular: 0x050505, shininess: 100 });
The color is provided in hexadecimal format, for instance if one wanted a bright red one would type 0xff0000
. I had a few issues with color fidelity in my model (my oranges looked yellow, many colors appeared washed out), to combat this I used THREE.Color
:
const color = new THREE.Color('#FF9A03').convertSRGBToLinear(); const material = new THREE.MeshPhongMaterial({ color: color.getHex(), transparent: false, opacity: 0.5, specular: 0x050505, shininess: 100 });
Create as many materials as you need and replace add them to the materials attribute of each mesh. As I wanted my model to have a transparent background, I also decided to remove the <color>
tag.
Conclusion
You should be done! This is the final code for my model, as well as the output it generates:
import { Canvas } from "@react-three/fiber"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader"; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader' import { OrbitControls, PerspectiveCamera } from '@react-three/drei' import { useLoader, } from "@react-three/fiber"; import * as THREE from 'three' export function Object(){ const gltf = useLoader(GLTFLoader, 'https://felixrunquist.com/felix_roundel_2.glb', loader => { const dracoLoader = new DRACOLoader(); dracoLoader.setDecoderPath('https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/js/libs/draco/'); loader.setDRACOLoader(dracoLoader); }) const { nodes, materials } = gltf; const color = new THREE.Color('#FF9A03').convertSRGBToLinear(); const ellipseMaterial = new THREE.MeshPhongMaterial({ color: color.getHex(), transparent: false, opacity: 0.5, specular: 0x050505, shininess: 100 }); const textMaterial = new THREE.MeshPhongMaterial({ color: 0x000000, transparent: false, opacity: 0.5, specular: 0x050505, shininess: 100 }); return ( <> <group dispose={null}> <pointLight name="Point Light" castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} shadow-camera-near={100} shadow-camera-far={100000} position={[-254.65, 150.88, -135.21]} ></pointLight> <group name="Group"> <mesh name="Text" geometry={nodes.Text.geometry} material={textMaterial} castShadow receiveShadow position={[-46.56, -1.88, -13.23]} rotation={[0, -Math.PI / 2, 0]} ></mesh> <mesh name="Ellipse" geometry={nodes.Ellipse.geometry} material={ellipseMaterial} castShadow receiveShadow position={[-8.73, 0, -25.43]} rotation={[0, -Math.PI / 2, 0]} scale={1} ></mesh> </group> <PerspectiveCamera name="1" makeDefault={true} far={100000} near={70} fov={45} position={[-949.88, 139.05, 279.99]} rotation={[-0.46, -1.25, -0.44]} scale={1} ></PerspectiveCamera> <hemisphereLight name="Default Ambient Light" intensity={0.75} color="#eaeaea" ></hemisphereLight> </group> </> ) } export default function Scene(){ return ( <div style={{width: '100vw', height: '100vh'}}> <Canvas shadows> <OrbitControls></OrbitControls> <Object></Object> </Canvas> </div> ) }
All done! I hope this helps you get started with Spline and Three.js. Let me know if you have any issues and what 3D projects you make!