<h1 id="">Generating and disposing of objects</h1>
<div id="count"><h2></h2>Balls</div>
<div id="instructions">Click / touch + hold to drop more balls.</div>
<canvas id="demo" />

@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700&display=swap');

* {
  -moz-user-select: none;  
  -ms-user-select: none;  
  -webkit-user-select: none;  
  user-select: none;  
}

body {
  margin: 0;
  padding: 0;
  overflow: hidden;
}

canvas {
  left: 0;
  margin: 0 auto;
  position: absolute;
  right: 0;
  z-index: 0;
}

h1 {
  background-color: rgba(0, 0, 0, .5);
  top: 0;
  color: #ddd;
  font-family: helvetica;
  font-size: 1.3rem;
  left: 0;
  margin: 0 auto;
  padding: 8px 16px; 
  position: absolute;
  right: 0;
  text-align: center;
  z-index: 1;
}

#count {
  background-color: rgba(0, 0, 0, .5);
  color: #fff;
  font-family: Orbitron;
  left: 0;
  margin: 0 auto;
  padding: 8px 16px; 
  position: absolute;
  text-align: center;
  right: 0;
  top: 80px;
  z-index: 1;
}

#count h2 {
  font-size: 60px;
  line-height: 60px;
  margin: 0;
}

#instructions {
  background-color: rgba(0, 0, 0, .5);
  bottom: 0;
  color: #fff;
  font-family: helvetica;
  left: 0;
  margin: 0 auto;
  padding: 8px 16px; 
  position: absolute;
  right: 0;
  text-align: center;
  z-index: 1;
}

import Stats from 'https://cdn.skypack.dev/stats.js';


class Scene extends THREE.Scene {
  #balls = []
  #ballGeometry
  #ballMaterial
  #ballRadius = 5
  #camera
  #controls
  #shouldDropBall = false
  #renderer
  #scene
  #timeOfLastBall
  #world
  #stats

  static #INITIAL_CAMERA_TARGET = new THREE.Vector3(0, 0, 0)
  static #MIN_FORCE_FOR_CAMERA_DISTURBANCE = 5
  static #SPACE_BAR_KEY_CODE = 32
  static #FPS = 15

  constructor () {
    super()

    const textureLoader = new THREE.TextureLoader()
    const ballTexture = textureLoader.load('https://assets.codepen.io/829639/TennisBallColorMap.jpeg')
    const ballBumpMap = textureLoader.load('https://assets.codepen.io/829639/TennisBallBump.jpeg')
    this.#ballGeometry = new THREE.SphereGeometry(this.#ballRadius, 32, 16)
    this.#ballMaterial = new THREE.MeshStandardMaterial({
      bumpMap: ballBumpMap,
      bumpScale: .25,
      color: 0xffffff,
      map: ballTexture,
      metalness: 0,
      opacity: 1, 
      roughness: 1,
      transparent: true,
    }, 50)
    this.fog = new THREE.Fog(0x000000, -10 , 1024)
    this.#world = new CANNON.World()
    this.#world.gravity.set(0, -30, 0)
    this.#world.broadphase = new CANNON.NaiveBroadphase()
    this.#world.defaultContactMaterial.restitution = .65

    this.#camera = new THREE.PerspectiveCamera(
      30,
      window.innerWidth / window.innerHeight,
      0.5,
      10000
    )
    this.#camera.position.set(0, 300, -225)
    this.#camera.lookAt(Scene.#INITIAL_CAMERA_TARGET)
    this.add(this.#camera)
    this.#makeLights()
    this.#makePlatform()
    
    // Drop first ball
    this.#makeBall()

    this.#renderer = new THREE.WebGLRenderer({ 
      antialias: true, 
      canvas: document.querySelector('#demo')
    })
    this.#renderer.setSize(window.innerWidth, window.innerHeight)
    this.#renderer.shadowMap.enabled = true
    document.body.appendChild(this.#renderer.domElement)
    
    this.#stats = new Stats();
    document.body.appendChild( this.#stats.dom );

    const setShouldDropBall = (value, e) => {
      e.preventDefault()
      e.stopPropagation()
      this.#shouldDropBall = value
    }

    document.body.addEventListener('mousedown', setShouldDropBall.bind(this, true))
    document.body.addEventListener('mouseup', setShouldDropBall.bind(this, false))
    document.body.addEventListener('touchstart', setShouldDropBall.bind(this, true))
    document.body.addEventListener('touchend', setShouldDropBall.bind(this, false))
    window.addEventListener('selectstart', e => {
      e.preventDefault()
      return false
    })

    this.#addControls()

    this.#renderer.setAnimationLoop(this.#render)
  }

  #dumpBalls () {
    const minTimeSinceLast = 10
    const timeDiff = Date.now() - this.#timeOfLastBall

    // throttle
    if (timeDiff < minTimeSinceLast) return

    this.#makeBall()
  }

  #addControls () {
    this.#controls = new THREE.OrbitControls(
      this.#camera,
      this.#renderer.domElement
    )
    this.#controls.enablePan = false
    this.#controls.minDistance = 0
    this.#controls.maxDistance = 1500
    this.#controls.rotateSpeed = 0.5
    this.#controls.keyPanSpeed = 140
    this.#controls.listenToKeyEvents(window)
  }

  #makeBall () {
    const sign = Math.round(Math.random()) ? 1 : -1
    const hasBalls = this.#balls.length > 0
    const x = hasBalls
      ? Math.floor(Math.random() * 25) * sign
      : 0
    const z = hasBalls
      ? Math.floor(Math.random() * 25) * sign
      : 0
    const y = 100

    const mesh = new THREE.Mesh(
      this.#ballGeometry.clone(),
      this.#ballMaterial.clone()
    )

    const shape = new CANNON.Sphere(this.#ballRadius)
    const body = new CANNON.Body({
      friction: 30,
      mass: 1
    })
    body.addShape(shape)
    body.position.set(x, y, z)
    body.quaternion.setFromAxisAngle(
      new CANNON.Vec3(1, 1, 1),
      Math.floor(Math.random() * Math.PI)
    )
    body.addEventListener('collide', this.#shakeOnCollision)
    this.#world.add(body)

    mesh.castShadow = true
    mesh.position.set(x, y, z)
    mesh.body = body
    this.#balls.push(mesh)
    this.add(mesh)

    this.#timeOfLastBall = Date.now()
  }

  #makeLights () {
    const ambientLight = new THREE.AmbientLight(0xffffff, .5)
    this.add(ambientLight)

    const light = new THREE.DirectionalLight(0xffffff, .9)
    const d = 200
 
    light.position.set(d, d, 0)
    light.castShadow = true
    light.shadow.mapSize.width = 1024
    light.shadow.mapSize.height = 1024
    light.shadow.camera.left = -d
    light.shadow.camera.right = d
    light.shadow.camera.top = d
    light.shadow.camera.bottom = -d
    light.shadow.camera.far = 3 * d
    light.shadow.camera.near = d
    light.shadow.darkness = 0.5

    this.add(light)
  }

  #makePlatform () {
    const size = 100
    const textureLoader = new THREE.TextureLoader()
    const geometry = new THREE.BoxGeometry(size, 2, size)
    const material = new THREE.MeshStandardMaterial({
      color: 0xffffff,
      bumpMap: textureLoader.load('https://assets.codepen.io/829639/tennis-court-bumpmap.png'),
      map: textureLoader.load('https://assets.codepen.io/829639/tennis-court-texture.jpeg'),
      metalness: .3,
      roughnessMap: textureLoader.load('https://assets.codepen.io/829639/tennis-court-roughmap.png'),
      roughness: 1,
      side: THREE.DoubleSide
    })
    const mesh = new THREE.Mesh(geometry, material)
    const shape = new CANNON.Box(new CANNON.Vec3(size / 2, 1, size / 2))
    const body = new CANNON.Body({
      linearDamping: 1,
      mass: 0
    })

    body.position = new CANNON.Vec3(0, 0, 0)
    body.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 1, 0), Math.PI / 2)
    body.addShape(shape)
    this.#world.add(body)

    mesh.receiveShadow = true
    mesh.body = body

    this.add(mesh)

    const lineMaterial = new THREE.MeshStandardMaterial({
      color: 0xffffff,
      metalness: .2,
      opacity: .8,
      roughnessMap: textureLoader.load('https://assets.codepen.io/829639/tennis-court-roughmap.png'),
      roughness: 1,
      transparent: 1,
    })
    const lineGeometry = new THREE.BoxGeometry(5, .1, size)
    const line = new THREE.Mesh(lineGeometry, lineMaterial)
    line.receiveShadow = true
    line.position.set(0, 1, 0)
    this.add(line)
  }

  #updateBalls = (ball, index, balls) => {
    ball.position.copy(ball.body.position)
    ball.quaternion.copy(ball.body.quaternion)
    
    const damping = ball.body.position.y < 6 &&
      ball.body.position.y > -1 ? .2 : 0
    ball.body.angularDamping = damping
    ball.body.linearDamping = damping

    const depth = ball.position.y
    const opacity = 1 + (depth / 50)
    ball.material.opacity = opacity

    // Dispose of balls once they've fallen far enough away
    if (opacity <= 0) {
      ball.geometry.dispose()
      ball.material.dispose()
      this.#world.removeBody(ball.body)
      this.remove(ball)
      this.#renderer.renderLists.dispose()
      balls.splice(index, 1)
    }
  }

  /**
   * Move the camera target on all three axes according to the force of the collision,
   * a little on x, more on y, and most on z,
   * and then quickly move it back after a short timeout.
   */
  #shakeOnCollision = e => {
    const force = e.contact.getImpactVelocityAlongNormal()

    if (force < Scene.#MIN_FORCE_FOR_CAMERA_DISTURBANCE) return

    this.#camera.lookAt(new THREE.Vector3(force / 400, force / 300, force / 200))
    setTimeout(() => this.#camera.lookAt(Scene.#INITIAL_CAMERA_TARGET), force)
  }

  #render = () => {
    const dt = 1 / Scene.#FPS
    const { innerHeight, innerWidth } = window
    this.#renderer.setSize(innerWidth, innerHeight)
    this.#camera.aspect = innerWidth / innerHeight
    this.#camera.updateProjectionMatrix()

    this.#world.step(dt)
    this.#stats.update()

    this.#balls.forEach(this.#updateBalls)

    if (this.#shouldDropBall) this.#dumpBalls()
    
    document.querySelector('#count h2').innerHTML = this.#balls.length.toString().padStart(3, '0')

    this.#renderer.render(this, this.#camera)
  }
}

new Scene()
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.min.js
  3. https://cdn.jsdelivr.net/npm/three@0.134.0/examples/js/controls/OrbitControls.js
  4. https://unpkg.com/three@0.128.0/examples/js/loaders/GLTFLoader.js