This article is the third in a beginner series on creating 3D scenes with svelte-cubed and three.js. If you want to learn how we got here you can start from the beginning:
Part One: Svelte-Cubed: An Introduction to 3D in the Browser
Part Two: Svelte-Cubed: Adding Motion to 3D Scenes
In this short article, we’re going to look at two sort-of unrelated topics. However, both fall under the umbrella of improving user experience:
Using
prefers-reduced-motion
to conditionally animate/transition things in our sceneUsing threejs clock and
getDelta()
to render the same motion for users with different device frame rates
Here’s the REPL where part two left off to get you started:
https://svelte.dev/repl/9b3b351fe187421b84a6f1616e2c9e3d
Conditional Motion: prefers-reduced-motion
Why does it matter?
Not everyone likes decorative animations or transitions, and some users outright experience motion sickness when faced with parallax scrolling, zooming effects, and so on. The user preference media query prefers-reduced-motion lets you design a motion-reduced variant of your site for users who have expressed this preference.
- Thomas Steiner (twitter) in prefers-reduced-motion: Sometimes less movement is more
And we want our scenes to be enjoyable for everyone, so first let’s figure out how to detect this preference in JavaScript so we can use it in our Svelte component. I’ll be using an approach from Geoff Rich (twitter) explained in his post A Svelte store for prefers-reduced-motion.
In a new javascript file we’ll call stores.js
we can steal all of Geoff’s code (and cite it for future reference!) and paste it in.
import { readable } from "svelte/store";
/*
Source: Geoff Rich,
"A Svelte store for prefers-reduced-motion",
URL: https://geoffrich.net/posts/svelte-prefers-reduced-motion-store/
*/
const reducedMotionQuery = '(prefers-reduced-motion: reduce)';
const getInitialMotionPreference = () => window.matchMedia(reducedMotionQuery).matches;
export const reducedMotion = readable(getInitialMotionPreference(), set => {
const updateMotionPreference = event => {
set(event.matches);
};
const mediaQueryList = window.matchMedia(reducedMotionQuery);
mediaQueryList.addEventListener('change', updateMotionPreference);
return () => {
mediaQueryList.removeEventListener('change', updateMotionPreference);
};
});
The reducedMotion
store provides a boolean that we can subscribe and react to anywhere in our application if the value changes. How can we use it? Well, anywhere we animate we can first check the preference and adjust as needed. Our motion is coming from two sources: the tweened store and our SC.onFrame()
callback.
First: if a user prefers reduced motion, our tweened store duration will be 0 (i.e. it will go from value a to value b instantly).
import { reducedMotion } from "./stores"
let scale = tweened(1, {duration: $reducedMotion ? 0 : 2000, easing: elasticOut});
That’s it? That’s it!
Second: if a user does not prefer reduced motion, we can update the rotation on every frame.
if(!$reducedMotion){
SC.onFrame(() => {
rotate += .01;
})
}
How can you test it? Geoff has us covered:
Here's how to simulate the setting in Chrome DevTools and where to enable the setting in various OSes and Firefox
Since you’re only simulating the setting in devtools, you’ll need to keep devtools open and refresh the REPL to see how things change.
Handling Variable Frame Rates Across Devices
What do all those words mean? Much smarter people can explain it better than me, but I’ll take a shot and then point you to a better explanation.
Some computer/monitor setups have powerful graphics and some do not. For example, our Octahedron is rotating 0.1 radians on each frame. So a setup running at 60 frames per second (fps) is watching your Octahedron rotate 0.1 radians 60 times every second. A less powerful setup running at 30fps is watching your Octahedron rotate 0.1 radians only 30 times every second. Those are very different experiences!
For a more accurate and in-depth explanation checkout The Animation Loop chapter from the open source book Discover three.js by Lewy Blue (twitter). If you are new to three.js I highly recommend taking some time to go through the whole book!
Basically we need a way to standardize our hardcoded value inside SC.onFrame
based on the current frame rate. We can do just that using the threejs Clock and the method getDelta()
and multiplying our value by delta.
const clock = new THREE.Clock();
SC.onFrame(() => {
rotate += .01 * clock.getDelta();
})
Wow, that’s slow. But it’s slow for everyone! Now we can adjust our rotation value to get the rate we want (try 0.5).
In the next article we’ll shift away from our Octahedron friend and dive into loading one or more glTF models into the scene!
References
REPL: https://svelte.dev/repl/c301f0ac026d45bdbf4facf55b921d1f?version=3.48.0
Octo.svelte
<script>
import * as THREE from "three";
import * as SC from "svelte-cubed";
import { tweened } from "svelte/motion"
import { elasticOut } from "svelte/easing"
import { reducedMotion } from "./stores"
let scaleType = "MEDIUM";
let scale = tweened(1, {duration: $reducedMotion ? 0 : 2000, easing: elasticOut});
// reactive statement to update scale based on scaleType
$: if (scaleType === "SMALL"){
$scale = .25;
} else if (scaleType === "MEDIUM"){
$scale = 1;
} else if (scaleType === "LARGE") {
$scale = 1.75;
}
let rotate = 0;
if(!$reducedMotion){
const clock = new THREE.Clock();
SC.onFrame(() => {
rotate += 0.5 * clock.getDelta();
})
}
</script>
<SC.Canvas background={new THREE.Color('seagreen')}>
<SC.AmbientLight
color={new THREE.Color('white')}
intensity={.5}
/>
<SC.DirectionalLight
color={new THREE.Color('white')}
intensity={.75}
position={[10, 10, 10]}
/>
<!-- MESHES -->
<SC.Mesh
geometry={new THREE.OctahedronGeometry()}
material={new THREE.MeshStandardMaterial({
color: new THREE.Color('salmon')
})}
rotation={[rotate, rotate, rotate]}
scale={[$scale, $scale, $scale]}
/>
<!-- CAMERA -->
<SC.PerspectiveCamera near={1} far={100} fov={55}>
</SC.PerspectiveCamera>
<SC.OrbitControls />
</SC.Canvas>
<div class="controls">
<label>
SMALL
<input type="radio" bind:group={scaleType} value="SMALL" />
</label>
<label>
MEDIUM
<input type="radio" bind:group={scaleType} value="MEDIUM" />
</label>
<label>
LARGE
<input type="radio" bind:group={scaleType} value="LARGE" />
</label>
</div>
<style>
.controls {
position: absolute;
top: .5rem;
left: .5rem;
background: #00000088;
padding: .5rem;
color: white;
}
</style>
stores.js
import { readable } from "svelte/store"
/*
Source: Geoff Rich,
"A Svelte store for prefers-reduced-motion",
URL: https://geoffrich.net/posts/svelte-prefers-reduced-motion-store/
*/
const reducedMotionQuery = '(prefers-reduced-motion: reduce)';
const getInitialMotionPreference = () => window.matchMedia(reducedMotionQuery).matches;
export const reducedMotion = readable(getInitialMotionPreference(), set => {
const updateMotionPreference = event => {
set(event.matches);
};
const mediaQueryList = window.matchMedia(reducedMotionQuery);
mediaQueryList.addEventListener('change', updateMotionPreference);
return () => {
mediaQueryList.removeEventListener('change', updateMotionPreference);
};
});
Top comments (0)