Getting started

What is Svelte Cubed?

Svelte Cubed is a component library for building Three.js scene graphs in Svelte apps. In the same way that Svelte declaratively abstracts over all the messiness of imperatively building and updating the DOM, Svelte Cubed makes it easy to build state-driven 3D graphics.

It is not yet done. You can use it, but you should expect missing features and APIs that aren’t yet set in stone.

Creating a project

If you don’t have a Svelte project to work on, create a new one with SvelteKit:

npm init svelte@next my-new-app
cd my-new-app
npm install
npm run dev

Then install Three.js and Svelte Cubed:

npm install three svelte-cubed

If you’re using TypeScript:

npm install -D @types/three

Now you’re ready to start building 3D graphics (in your project’s src/routes/index.svelte, for example).

Your first scene

<script>
	import * as THREE from 'three';
	import * as SC from 'svelte-cubed';
</script>

<SC.Canvas>
	<SC.Mesh geometry={new THREE.BoxGeometry()} />
	<SC.PerspectiveCamera position={[1, 1, 3]} />
</SC.Canvas>

If you like, you can open this demo in StackBlitz and follow along.

Let’s break this down.

First, we’re importing THREE and SC from three and svelte-cubed respectively. In general, Svelte Cubed doesn’t wrap the underlying Three.js objects, it simply gives you a place to put them, so whenever you’re creating things like geometry or materials you will need to import three.

The <SC.Canvas> component creates a WebGLRenderer and a Scene. Child components will be added to the scene.

A mesh is a combination of geometry and material. In this case, <SC.Mesh> is creating a mesh with BoxGeometry and the default MeshNormalMaterial.

Finally, we’re adding a PerspectiveCamera so that we can see our mesh. The position attribute represents the x, y and z components of a Vector3.

The equivalent Three.js code would look something like this:

import * as THREE from 'three';

function render(element) {
	const scene = new THREE.Scene();
	const camera = new THREE.PerspectiveCamera(
		45,
		element.clientWidth / element.clientHeight,
		0.1,
		2000
	);

	const renderer = new THREE.WebGLRenderer();
	renderer.setSize(element.clientWidth / element.clientHeight);
	element.appendChild(renderer.domElement);

	const geometry = new THREE.BoxGeometry();
	const material = new THREE.MeshNormalMaterial();
	const box = new THREE.Mesh(geometry, material);
	scene.add(box);

	camera.position.x = 2;
	camera.position.y = 2;
	camera.position.z = 5;

	camera.lookAt(new THREE.Vector3(0, 0, 0));

	renderer.render(scene, camera);
}

As you can see, it’s a lot more code — more than three times as much, by character count — and we haven’t started adding state changes, or animation, or hierarchies of objects, or any of the other things that cause imperative code to get tangled up. Over time, a component framework makes it vastly easier to keep track of the relationships between the different parts of your scene.

Customising the canvas

Let’s make things look a bit nicer by adding some options. The <SC.Canvas> component takes properties corresponding to Scene options, like background, and WebGLRenderer options like antialias:

-<SC.Canvas>
+<SC.Canvas antialias background={new THREE.Color('papayawhip')}>
	<SC.Mesh geometry={new THREE.BoxGeometry()} />
	<SC.PerspectiveCamera position={[1, 1, 3]} />
​</SC.Canvas>

Controlling the camera

If you’re like me, the first thing you did when you saw the cube above was to try and spin it. That won’t work until we add some controls:

​<SC.Canvas antialias background={new THREE.Color('papayawhip')}>
	<SC.Mesh geometry={new THREE.BoxGeometry()} />
	<SC.PerspectiveCamera position={[1, 1, 3]} />
+	<SC.OrbitControls enableZoom={false} />
​</SC.Canvas>

Try spinning the camera around!

Note that the scene is only re-rendered when necessary — when the spinning stops, so does the rendering.

Living in a material world

The default MeshNormalMaterial is useful for debugging, but most of the time you’ll want to specify something else, such as MeshStandardMaterial:

​<SC.Canvas antialias background={new THREE.Color('papayawhip')}>
-	<SC.Mesh geometry={new THREE.BoxGeometry()} />
+	<SC.Mesh
+		geometry={new THREE.BoxGeometry()}
+		material={new THREE.MeshStandardMaterial({ color: 0xff3e00 })}
+	/>
	<SC.PerspectiveCamera position={[1, 1, 3]} />
	<SC.OrbitControls enableZoom={false} />
​</SC.Canvas>

Since MeshStandardMaterial uses physically-based rendering, we’ll need to illuminate the mesh.

Let there be light

Svelte Cubed provides components corresponding to the various lights in Three.js, such as AmbientLight and DirectionalLight:

​<SC.Canvas antialias background={new THREE.Color('papayawhip')}>
	<SC.Mesh
		geometry={new THREE.BoxGeometry()}
		material={new THREE.MeshStandardMaterial({ color: 0xff3e00 })}
	/>
	<SC.PerspectiveCamera position={[1, 1, 3]} />
	<SC.OrbitControls enableZoom={false} />
+	<SC.AmbientLight intensity={0.6} />
+	<SC.DirectionalLight intensity={0.6} position={[-2, 3, 2]} />
​</SC.Canvas>

Updating state

Svelte Cubed components are just like any other component, which means we can update state in the normal way:

​<script>
	import * as THREE from 'three';
	import * as SC from 'svelte-cubed';
+
+	let width = 1;
+	let height = 1;
+	let depth = 1;
​</script>

​<SC.Canvas antialias background={new THREE.Color('papayawhip')}>
	<SC.Mesh
		geometry={new THREE.BoxGeometry()}
		material={new THREE.MeshStandardMaterial({ color: 0xff3e00 })}
+		scale={[width, height, depth]}
	/>
	<SC.PerspectiveCamera position={[1, 1, 3]} />
	<SC.OrbitControls enableZoom={false} />
	<SC.AmbientLight intensity={0.6} />
	<SC.DirectionalLight intensity={0.6} position={[-2, 3, 2]} />
​</SC.Canvas>

+<div class="controls">
+	<label><input type="range" bind:value={width} min={0.1} max={3} step={0.1} /> width</label>
+	<label><input type="range" bind:value={height} min={0.1} max={3} step={0.1} /> height</label>
+	<label><input type="range" bind:value={depth} min={0.1} max={3} step={0.1} /> depth</label>
+</div>
+
+<style>
+	.controls {
+		position: absolute;
+	}
+</style>

Adding motion

Since we’re in a Svelte component, we have access to tweened and spring stores and can use them to update component properties.

Sometimes, though, it’s convenient to be able to update state on every frame. For these cases, Svelte Cubed provides an onFrame lifecycle function:

​<script>
	import * as THREE from 'three';
	import * as SC from 'svelte-cubed';

	let width = 1;
	let height = 1;
	let depth = 1;
+
+	let spin = 0;
+
+	SC.onFrame(() => {
+		spin += 0.01;
+	});
​</script>

​<SC.Canvas antialias background={new THREE.Color('papayawhip')}>
	<SC.Mesh
		geometry={new THREE.BoxGeometry()}
		material={new THREE.MeshStandardMaterial({ color: 0xff3e00 })}
		scale={[width, height, depth]}
+		rotation={[0, spin, 0]}
	/>
	<SC.PerspectiveCamera position={[1, 1, 3]} />
	<SC.OrbitControls enableZoom={false} />
	<SC.AmbientLight intensity={0.6} />
	<SC.DirectionalLight intensity={0.6} position={[-2, 3, 2]} />
​</SC.Canvas>

Finishing touches

Let’s add a few more tweaks. First, let’s make the box cast a shadow:

​<SC.Mesh
	geometry={new THREE.BoxGeometry()}
	material={new THREE.MeshStandardMaterial({ color: 0xff3e00 })}
	scale={[width, height, depth]}
	rotation={[0, spin, 0]}
+	castShadow
/>

Then we’ll add an <SC.Group> containing a plane to receive the shadow (rotated so that it’s horizontal) and a GridHelper. The grid helper is added using the <SC.Primitive> component, and positioned 0.001 units above the plane to avoid z-fighting:

+<SC.Group position={[0, -height / 2, 0]}>
+	<SC.Mesh
+		geometry={new THREE.PlaneGeometry(50, 50)}
+		material={new THREE.MeshStandardMaterial({ color: 'burlywood' })}
+		rotation={[-Math.PI / 2, 0, 0]}
+		receiveShadow
+	/>
+
+	<SC.Primitive
+		object={new THREE.GridHelper(50, 50, 0x444444, 0x555555)}
+		position={[0, 0.001, 0]}
+	/>
+</SC.Group>

Before the shadows will work, we need to enable them at the canvas level…

-<SC.Canvas antialias background={new THREE.Color('papayawhip')}>
+<SC.Canvas antialias background={new THREE.Color('papayawhip')} shadows>

…and on the light source (specifying a larger-than-default mapSize to avoid blurriness):

-<SC.DirectionalLight intensity={0.6} position={[-2, 3, 2]} />
+<SC.DirectionalLight intensity={0.6} position={[-2, 3, 2]} shadow={{ mapSize: [2048, 2048] }} />

Add a maxPolarAngle so the camera can’t dip below the floor:

-<SC.OrbitControls enableZoom={false} />
+<SC.OrbitControls enableZoom={false} maxPolarAngle={Math.PI * 0.51} />

Finally, add a fog property to the canvas:

​<SC.Canvas
	antialias
	background={new THREE.Color('papayawhip')}
+	fog={new THREE.FogExp2('papayawhip', 0.1)}
	shadows
>

Play with the finished version on StackBlitz.