Three JS - Simple Shelf Builder

This project is an experiment with Three.js and React-Three-Fiber. It's a shelf builder. With this project, I want to learn how to rescale models and manipulate them in the scene using external controllers.

Juan Méndez avatar
Juan Méndez📆 2025-03-21
Play

Building a 3D Shelf Constructor with Three.js and Blender

This article details my process of creating an interactive 3D shelf customizer using Three.js, React, and Blender. The project allows users to customize dimensions, divisions, and colors of a shelf in real-time directly in the browser.

Preview of the shelf constructor

Modeling the Scene in Blender

The first step of this project was to create the environment where the shelf would be displayed. I used Blender to model a simple but effective scene consisting of:

  • A horizontal plane that functions as the floor
  • A vertical plane that acts as a background wall
  • Appropriate lighting to cast realistic shadows
Blender modeling process

The modeling in Blender was intentionally minimalist to keep the focus on the customizable shelf. After creating the base model, I exported it as a GLB file, which is a web-optimized format that contains both the geometry and materials of the model.

Tip:

When creating 3D models for the web, it's crucial to maintain a low polygon count. A good practice is not to exceed 100,000 polygons for the entire scene in real-time applications.

Converting GLB File to JSX

To convert a GLB file into a JSX file, you can use the @react-three/drei library, which provides a useGLTF hook to load GLB files. Here are the steps to achieve this:

  1. Install the necessary dependencies: Make sure you have @react-three/fiber and @react-three/drei installed in your project. You can install them using npm or yarn:

    npm install @react-three/fiber @react-three/drei
    # or
    yarn add @react-three/fiber @react-three/drei
    
  2. Load the GLB file: Use the useGLTF hook from @react-three/drei to load the GLB file. This hook returns the nodes and materials of the model, which you can use to create a JSX component.

  3. Use gltfjsx to automatically generate a JSX component: You can use the gltfjsx CLI tool to automatically convert a GLB file into a React component. Run the following command:

    npx gltfjsx ./src/scenario.glb
    

    This command processes your GLB file and generates a JSX component with all the meshes, materials, and animations properly set up. Some useful options include:

    • --transform: Apply transforms from the original file
    • --types: Generate TypeScript definitions
    • --shadows: Add shadow support automatically
    • --precision=n: Number of decimal places (default is 2)
  4. Create a JSX component: Create a new component that uses the loaded GLB model. You can configure the materials, geometry, and other properties as needed.

Here is an example of how to convert a GLB file into a JSX component:

export function Scenario(props) {
  const { nodes, materials } = useGLTF(`${basePath}models/scenario.glb`);

  // Texture configuration...

  return (
    <group {...props} dispose={null}>
      <mesh
        geometry={nodes.Plane.geometry}
        scale={[2, 1, 3]}
        receiveShadow={props.receiveShadow}
      >
        <meshStandardMaterial {...floorTextureProps} />
      </mesh>
      <mesh
        geometry={nodes.Plane001.geometry}
        material={materials["Material.002"]}
        position={[-2, 1.5, 0]}
        rotation={[-Math.PI, 0, Math.PI / 2]}
        scale={[1.5, 1, 3]}
        receiveShadow={props.receiveShadow}
      />
    </group>
  );
}
Tip:

If you're creating your own project with 3D models, the gltfjsx tool is extremely useful for converting GLB files to React components. This greatly facilitates integration with React Three Fiber and allows more intuitive manipulation of models.

Dynamic Rendering with React Three Fiber

React Three Fiber is a library that allows using Three.js declaratively within React. For this project, I implemented a dynamic rendering system that:

  1. Only loads and renders the 3D scene when it's visible in the viewport
  2. Displays a static image when not active, improving performance
  3. Offers intuitive camera controls to examine the shelf

The most interesting part was the implementation of the Experience component, which manages the complete 3D scene:

export const Experience = () => {
  const controlsRef = useRef(null);
  const { camera } = useThree();

  // Camera limit configuration
  const minDistance = 1.5;
  const maxDistance = 5;

  useEffect(() => {
    if (!controlsRef.current) return;
    camera.position.setLength(
      Math.max(minDistance, Math.min(camera.position.length(), maxDistance))
    );
  }, [camera]);

  return (
    <>
      <color attach="background" args={["#121212"]} />
      <ambientLight intensity={0.5} />
      {/* Lights configuration */}
      <Shelf />
      <Scenario
        receiveShadow
        scale={[1, 1, 1]}
        position={[1.5, 0, 1]}
        rotation={[0, Math.PI / 10, 0]}
      />
      <OrbitControls
        ref={controlsRef}
        minDistance={minDistance}
        maxDistance={maxDistance}
        enableZoom={true}
        enablePan={true}
        enableRotate={true}
        maxPolarAngle={Math.PI / 3}
        minPolarAngle={0}
        minAzimuthAngle={Math.PI / 6}
        maxAzimuthAngle={Math.PI / 1.5}
      />
    </>
  );
};
Experience component rendered
Tip:

To improve performance on mobile devices, the component only renders the 3D scene when it's visible in the viewport. When not active, a static image is displayed, which significantly reduces resource consumption.

Dynamic Model Properties Iteration

The heart of the project is the ability to modify the shelf properties in real-time. I implemented a state system based on React Context to manage these properties:

  • Dimensions (width, height, depth)
  • Number of columns and rows
  • Material color
Real-time property modification

The Shelf component is responsible for rendering the different parts of the shelf based on these parameters:

export const Shelf = () => {
  const { props } = useShelfProps();

  const width = props.width / 100;
  const height = props.height / 100;
  const depth = props.depth / 100;
  const color = props.color;
  const columns = props.columns;
  const rows = props.rows;

  // Calculations to ensure minimum dimensions
  const adjustedColumns = Math.floor(width / minSectionWidth);
  const adjustedRows = Math.floor(height / minSectionHeight) - 1;

  return (
    <group>
      {/* Shelf base */}
      <Table
        position={[0, 0 + offset, 0]}
        size={[depth, tableSize, width]}
        color={color}
      />

      {/* Top part */}
      <Table
        position={[0, height + tableSize, 0]}
        size={[depth, tableSize, width]}
        color={color}
      />

      {/* Side panels */}
      <Table
        position={[0, height / 2 + tableSize, -width / 2 + offset]}
        size={[depth, height, tableSize]}
        color={color}
      />
      <Table
        position={[0, height / 2 + tableSize, width / 2 - offset]}
        size={[depth, height, tableSize]}
        color={color}
      />

      {/* Dynamic generation of vertical divisions */}
      {Array.from({ length: Math.min(columns - 1, adjustedColumns - 1) }).map(
        (_, i) => {
          const posZ =
            -width / 2 + (width / Math.min(columns, adjustedColumns)) * (i + 1);
          return (
            <Table
              key={`column-divider-${i}`}
              position={[0, height / 2 + tableSize, posZ]}
              size={[depth, height, tableSize]}
              color={color}
            />
          );
        }
      )}

      {/* Dynamic generation of horizontal shelfs */}
      {Array.from({ length: Math.min(rows, adjustedRows) }).map((_, i) => {
        const effectiveRows = Math.min(rows, adjustedRows);
        const tablesThickness = (effectiveRows + 2) * tableSize;
        const availableHeight = height - tablesThickness;
        const gap = availableHeight / (effectiveRows + 1);
        const posY = tableSize + gap * (i + 1) + tableSize * i;
        return (
          <Table
            key={`row-shelf-${i}`}
            position={[0, posY, 0]}
            size={[depth, tableSize, width]}
            color={color}
          />
        );
      })}
    </group>
  );
};
Warning:

Although the model allows extensive customization, there are physical limits to consider. If you reduce dimensions too much or add too many divisions, the shelf might look unrealistic or unstable in a real environment.

Custom Texture Application

To add realism to the scene, I implemented a system of procedural textures for both the environment and the shelf. I used resources from sites like Texture Ninja and 3D Textures to obtain high-quality texture maps.

Applied texture detail

The Table component handles the application of textures on each element of the shelf:

export const Table = ({
  size = [0.2, 0.02, 0.3],
  color = "#f7f7f2",
  ...props
}) => {
  const textureProps = useTexture({
    aoMap: `${basePath}assets/textures/plank_ao.jpg`,
    normalMap: `${basePath}assets/textures/plank_normal.jpg`,
    roughnessMap: `${basePath}assets/textures/plank_roughness.jpg`,
  });

  // Texture repeat configuration
  textureProps.aoMap.repeat.set(textureRepeat, textureRepeat);
  textureProps.normalMap.repeat.set(textureRepeat, textureRepeat);
  textureProps.roughnessMap.repeat.set(textureRepeat, textureRepeat);

  // Wrapping configuration to avoid seams
  textureProps.aoMap.wrapS = textureProps.aoMap.wrapT = THREE.RepeatWrapping;
  textureProps.normalMap.wrapS = textureProps.normalMap.wrapT =
    THREE.RepeatWrapping;
  textureProps.roughnessMap.wrapS = textureProps.roughnessMap.wrapT =
    THREE.RepeatWrapping;

  return (
    <mesh {...props} receiveShadow castShadow>
      <boxGeometry args={size} />
      <meshStandardMaterial {...textureProps} color={color} map={null} />
    </mesh>
  );
};

For the environment, I applied a more complete set of texture maps to create a realistic floor:

const floorTextureProps = useTexture({
  map: `${basePath}assets/textures/floor_BaseColor.jpg`,
  normalMap: `${basePath}assets/textures/floor_Normal.jpg`,
  aoMap: `${basePath}assets/textures/floor_ao.jpg`,
  roughnessMap: `${basePath}assets/textures/floor_Roughness.jpg`,
  heightMap: `${basePath}assets/textures/floor_height.jpg`,
});
Note:

The textures used in this project are procedural and applied in real-time. This allows changing the shelf color without needing to load new textures, which significantly improves performance.

Conclusion

This project allowed me to explore the integration between Blender and Three.js to create an interactive web application with customizable 3D models. The main challenges included managing real-time performance and creating an intuitive user interface to manipulate the model properties.

To try out the shelf constructor, simply interact with the controls on the right side of the canvas and watch how the model updates in real-time.

Warning:

On mobile devices, camera controls may be less intuitive than on desktop. It's recommended to use two fingers to zoom and rotate the view for a better experience.

Special thanks to Katsukagi for making the textures available for free. Here is his Patreon link: Katsukagi if you would like to support him.