← gallery

interiors

Bookshelf

Procedurally generated bookshelf with seeded books and one cuboid collider.

Install

npx runek add bookshelf

Pulls: core@react-three/rapier@^2.2.0three@^0.184.0

Props

export interface BookshelfProps {
  position?: Vec3
  rotation?: Vec3
  /** Outer dimensions in units. */
  width?: number
  height?: number
  depth?: number
  shelves?: number
  /** Fraction of shelf space filled with books, 0–1. */
  fill?: number
  /** Frame color. Defaults to the world palette's `wood` slot. */
  color?: string
  /** Back-panel color. Defaults to the world palette's `woodDark` slot. */
  backColor?: string
  seed?: number
}

Source

Bookshelf.tsx
import { CuboidCollider, RigidBody } from '@react-three/rapier'
import { rng, useWorld, type Vec3 } from '@runek/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'

export interface BookshelfProps {
  position?: Vec3
  rotation?: Vec3
  /** Outer dimensions in units. */
  width?: number
  height?: number
  depth?: number
  shelves?: number
  /** Fraction of shelf space filled with books, 0–1. */
  fill?: number
  /** Frame color. Defaults to the world palette's `wood` slot. */
  color?: string
  /** Back-panel color. Defaults to the world palette's `woodDark` slot. */
  backColor?: string
  seed?: number
}

interface Book {
  x: number
  y: number
  width: number
  height: number
  depth: number
  /** Spine tilt around z, radians — an occasional leaning book. */
  lean: number
  color: THREE.Color
}

export function Bookshelf({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  width = 1.2,
  height = 2,
  depth = 0.3,
  shelves = 4,
  fill = 0.7,
  color,
  backColor,
  seed = 1,
}: BookshelfProps) {
  const { unit, palette } = useWorld()
  const frameColor = color ?? palette.wood
  const panelColor = backColor ?? palette.woodDark
  const w = width * unit
  const h = height * unit
  const d = depth * unit
  const plank = 0.03 * unit
  const inner = w - plank * 2
  const gap = (h - plank) / shelves
  const booksRef = useRef<THREE.InstancedMesh>(null)

  const books = useMemo<Book[]>(() => {
    const next = rng(seed)
    const out: Book[] = []
    for (let shelf = 0; shelf < shelves; shelf++) {
      let x = -inner / 2
      while (x < inner / 2) {
        const bw = (0.025 + next() * 0.03) * unit
        if (x + bw > inner / 2) break
        const bh = gap * (0.55 + next() * 0.35)
        const keep = next() < fill
        const lean = next() < 0.12 ? (next() - 0.5) * 0.22 : 0
        const hue = next() * 360
        const sat = 30 + next() * 25
        const light = 42 + next() * 20
        if (keep) {
          out.push({
            x: x + bw / 2,
            y: -h / 2 + plank + gap * shelf + bh / 2,
            width: bw,
            height: bh,
            depth: d * (0.5 + next() * 0.2),
            lean,
            color: new THREE.Color(`hsl(${hue}, ${sat}%, ${light}%)`),
          })
        }
        x += bw + 0.004 * unit
      }
    }
    return out
  }, [h, d, inner, gap, plank, shelves, fill, seed, unit])

  useLayoutEffect(() => {
    const mesh = booksRef.current
    if (!mesh) return
    const dummy = new THREE.Object3D()
    books.forEach((book, i) => {
      dummy.position.set(book.x, book.y, 0)
      dummy.rotation.set(0, 0, book.lean)
      dummy.scale.set(book.width, book.height, book.depth)
      dummy.updateMatrix()
      mesh.setMatrixAt(i, dummy.matrix)
      mesh.setColorAt(i, book.color)
    })
    mesh.count = books.length
    mesh.instanceMatrix.needsUpdate = true
    if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true
  }, [books])

  const plankYs = Array.from({ length: shelves + 1 }, (_, i) => -h / 2 + plank / 2 + gap * i)

  return (
    <RigidBody type="fixed" colliders={false} position={position} rotation={rotation}>
      {/* one collider for the whole footprint — books stay visual-only */}
      <CuboidCollider args={[w / 2, h / 2, d / 2]} />

      <mesh castShadow receiveShadow position={[-w / 2 + plank / 2, 0, 0]}>
        <boxGeometry args={[plank, h, d]} />
        <meshStandardMaterial color={frameColor} />
      </mesh>
      <mesh castShadow receiveShadow position={[w / 2 - plank / 2, 0, 0]}>
        <boxGeometry args={[plank, h, d]} />
        <meshStandardMaterial color={frameColor} />
      </mesh>
      <mesh castShadow receiveShadow position={[0, 0, -d / 2 + plank / 2]}>
        <boxGeometry args={[w, h, plank]} />
        <meshStandardMaterial color={panelColor} />
      </mesh>

      {plankYs.map((y) => (
        <mesh key={`plank-${y.toFixed(4)}`} castShadow receiveShadow position={[0, y, 0]}>
          <boxGeometry args={[inner, plank, d]} />
          <meshStandardMaterial color={frameColor} />
        </mesh>
      ))}

      {/* all books in one draw call */}
      {books.length > 0 && (
        <instancedMesh
          key={books.length}
          ref={booksRef}
          args={[undefined, undefined, books.length]}
          castShadow
        >
          <boxGeometry args={[1, 1, 1]} />
          <meshStandardMaterial roughness={0.85} />
        </instancedMesh>
      )}
    </RigidBody>
  )
}