Developing a Symptom Library for Shinedown in Three.js

A Symptom of Being Human

Lee Martin
7 min readNov 18, 2023
Shinedown

Reposted from my dev blog: leemartin.com/symptom-library

When Shinedown released their latest single “A Symptom of Being Human,” about those unique trials in one’s life which help to define their existence, fans reacted that they could fill a library with their own personal moments. So, we built them one.

The Symptom Library is a home for fans to write their own unique short story from their life and read what other fans have contributed. Modeled after prestigious vintage public libraries, our library exists as an infinite scrolling shelf filled with dynamically designed books containing short stories from fans. Clicking on any book will pull it off the shelf, open it up, and redirect to a unique page where the enclosed story can be read and shared. So far, fans have contributed hundreds of stories as they recognize everything we face is all just a symptom of being human.

Explore the Symptom Library and read on to learn how we developed the infinite bookshelf component using Three.js.

Low Poly Book

Three.js is used to build out our library, book by book. Let’s first discuss how we’ve developed a simple low poly book in Three, then we’ll explain how to organize an infinite shelf of them.

Structure

The low poly book design I landed on couldn’t be simpler. The pages are a Box and the covers/spine are Planes.

Book

We’ll start by creating a new Object3D to hold each of these elements. This allows us to animate and transform the book and all of it’s elements at once.

// Book
const book = new THREE.Object3D()

Pages

We can then add the pages, which for our purpose is just a BoxGeometry with a paper texture. The entire book will be scaled 6 x 9 so we’ll make the pages a bit smaller so the covers overlap them slightly and position them so the pages do not overlap the spine.

// Geometry
const geometry = new THREE.BoxGeometry(5.9, 8.8, 0.95)

// Material
const material = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('paper.jpg')
})

// Pages
const pages = new THREE.Mesh(geometry, material)

// Position
pages.position.set(-0.05, 0, 0)

// Add to book
book.add(pages)

Back cover

You’d think we would discuss the front cover next but we’ll actually save that for last because it is a bit more complicated than the back cover and spine because it will need to animate (open.) Let’s look at the back cover first. The back cover is just a PlaneGeometry rotated and positioned behind the pages.

// Geometry
const geometry = new THREE.PlaneGeometry(6, 9)

// Rotate
geometry.rotateY(THREE.MathUtils.degToRad(-180))

// Material
const material = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('backCover.jpg'),
side: THREE.DoubleSide
})

// Back cover
const backCover = new THREE.Mesh(geometry, material)

// Position
backCover.position.z = -0.5

// Add to book
book.add(backCover)

Spine

Next, we can add the spine. The spine is another, albeit smaller, PlaneGeometry. Unlike the covers, it has a smaller width of only 1 and needs to be positioned all the way to the edge of the pages.

// Geometry
const geometry = new THREE.PlaneGeometry(1, 9)

// Rotate
geometry.rotateY(THREE.MathUtils.degToRad(-90))

// Material
const material = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('spine.jpg'),
side: THREE.DoubleSide
})

// Spine
const spine = new THREE.Mesh(geometry, material)

// Position
spine.position.x = -3

// Add to book
book.add(spine)

Front cover

Now we can finally get to the front cover. Unlike all of the other elements of our book, the front cover must actually rotate open so users can get a peek at the inside. On our experience, we actually open the book to a blank page and then fade to another reading view. (You can see that on the demo video above.) Regardless, we do need to animate this cover. In addition, since the front cover is opening, we’re getting a look at the backside of it and it shouldn’t just be the front cover again. So, what we need is a Group which can hold both an outside and inside front cover. This Group will also help us greatly when it comes to creating the cantilever opening rotation.

Let’s start by creating our front cover group and positioning it at the edge we want it to open from.

// Front cover
const frontCover = new THREE.Group()

// Position
frontCover.position.set(-3, -4.5, 0.5)

// Add to book
book.add(frontCover)

Next we can add our inside cover. Pay special attention to the side param of the material because this allows both our inside and outside covers to live in the same space but only appear on their associated side. Also, note how the inside cover is positioned so that it's left edge aligns with the front covers edge. Again, this makes for much easier animating when opening the book.

// Geometry
const geometry = new THREE.PlaneGeometry(6, 9)

// Material
const material = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('frontInsideCover.jpg'),
side: THREE.BackSide
})

// Inside cover
const insideCover = new THREE.Mesh(geometry, material)

// Position
insideCover.position.set(3, 4.5, 0)

// Add to front cover
frontCover.add(insideCover)

We can then do the same for the outside cover. This time we’ll set the side to FrontSide.

// Geometry
const geometry = new THREE.PlaneGeometry(6, 9)

// Material
const material = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('frontOutsideCover.jpg'),
side: THREE.FrontSide
})

// Outside cover
const outsideCover = new THREE.Mesh(geometry, material)

// Position
outsideCover.position.set(3, 4.5, 0)

// Add to front cover
frontCover.add(outsideCover)

Now that we’ve added all of the elements of our book, we can finally animate the cover open.

Animation

To animate the cover open, we’ll use a simple Greensock tween on the frontCover group's rotation at the edge we specified. The reason this is so simple is because of the well positioned group and planes. Without that setup, this would be a bit more complicated. In fact, I'm not sure what to do. 😅

// Open book
gsap.to(frontCover.rotation, {
duration: 5,
y: THREE.MathUtils.degToRad(-180)
})

Here’s a CodePen of this entire setup.

Textures

Just a quick word here on textures. This example uses a single set of manually designed texture images but in reality our experience generated dynamic covers for all of our books based on data coming from the database. This is done by using HTML Canvas to generate each cover in realtime and then using a CanvasTexture on Three.js to display them. Honestly, this wasn’t the most performant way to do things but it did give us the ability to make as many design adjustments as required.

Shelf

Shelf Mockup

One book is cool but you know what would be cooler? Infinite books. Yes, our vision here is that each time a fan submitted a new story, it would be added to an ever growing shelf. Let’s discuss.

Structure

There isn’t much structure to our shelf. Surprise, there isn’t a shelf at all. I’m just adding each dynamic book to the scene and spacing them out a bit using a bookOffset because it looked more aesthetically pleasing to me. I decided I didn't need a shelf Group when I realized how I was going to allow users to navigate the shelf; by moving the Camera.

Navigation

Honestly the biggest learning of this project for me was figuring out how users navigated the shelf on both computer and mobile devices. Let’s start with the obvious, using touch on mobile devices to scroll through the shelf.

Hammer

To achieve this, we’ll use Hammer.js to listen for pans on the renderer dom element. As users pan, we’ll just the velocityX event property to adjust the camera position but make sure to clamp this position so they aren't allowed to pan beyond the beginning or end of the shelf. Then, when the user ends their pan, we can animate to an estimated settled position so the pan ends smoothly.

// Hammer
const hammer = new Hammer(renderer.domElement)

// Set horizontal pan
hammer.get('pan').set({
enable: true,
direction: Hammer.DIRECTION_HORIZONTAL
})

// Pan move
hammer.on('panmove', e => {
// Position camera
camera.position.x -= e.velocityX
// Clamp
camera.position.x = THREE.MathUtils.clamp(camera.position.x, 0, books.length * bookOffset - bookOffset)
})

// Pan end
hammer.on('panend', e => {
// Animate to position estimated from velocity
gsap.to(camera.position, {
x: camera.position.x - (e.velocityX * 10),
onUpdate() {
// Clamp
camera.position.x = THREE.MathUtils.clamp(camera.position.x, 0, books.length * bookOffset - bookOffset)

}
})
})

We can also use Hammer to listen for taps and determine when one of our books were chosen. In order to actually determine which book was tapped, we’ll need a Raycaster and Pointer combo to detect intersections.

// Tap
hammer.on('tap', e => {
// Update pointer
pointer.x = (e.center.x / window.innerWidth) * 2 - 1
pointer.y = -(e.center.y / window.innerHeight) * 2 + 1

// Set raycaster
raycaster.setFromCamera(pointer, camera)

// Check for intersects
const intersects = raycaster.intersectObjects(books)

// If intersects
if (intersects.length) {
// Book selected
}

})

Mousewheel

In addition to using panning with touch to navigate the bookshelf, I was curious about using the mousewheel on my computer. Honestly, this is all reminding me of the classic MacOS cover flow UX. This turned out to be pretty simple to setup, I just needed to listen for the wheel event and adjust the camera accordingly. Don't forget to clamp or you'll wheel into infinity.

// On wheel
window.addEventListener('wheel', e => {
// Prevent default
e.preventDefault()

// Position
camera.position.x += e.deltaX * 0.005

// Clamp
camera.position.x = THREE.MathUtils.clamp(camera.position.x, 0, books.length * bookOffset - bookOffset)

}, {
passive: false
})

Acknowledgements

Thanks again to Freddie Morris, Alison Shepard, and Morgan King and their respective teams at Atlantic Records and Elektra Records for once again trusting me with a Shinedown campaign. This is the 3rd project we’ve developed together and I love how both the band and fans embrace these activations.

--

--

Netmaker. Playing the Internet in your favorite band for two decades. Previously Silva Artist Management, SoundCloud, and Songkick.