Skip to content

Baseball Pitch 3D Visualization

Based on the 3D visualization by Baseball Savant and the work by Saffo et al. Pitch trajectories are simulated data, and ball positions are their actual recorded positions when crossing the home plate, hence the visual discrepancy between them. Strike zone size and position are rough approximations for example purposes. Animations are played at approximately real speed. Refresh to restart the animations.

js
// SPDX-License-Identifier: Apache-2.0
// Copyright : J.P. Morgan Chase & Co.

import * as anu from '@jpmorganchase/anu';
import * as BABYLON from '@babylonjs/core';
import * as GUI from '@babylonjs/gui';
import * as d3 from 'd3';
import earcut from 'earcut';  //External dependecy required for PolygonMeshBuilder
import data from './data/pitches.json';

export function pitches(engine){

  //Create an empty Scene
  const scene = new BABYLON.Scene(engine);
  //Add some lighting
  new BABYLON.HemisphericLight('light1', new BABYLON.Vector3(0, 10, -10), scene);
  //Add a camera that rotates around the origin and adjust its properties
  const camera = new BABYLON.ArcRotateCamera('Camera', 0, 0, 0, new BABYLON.Vector3(-0.25, 0.5, 0), scene);
  camera.position = new BABYLON.Vector3(-0.75, 0.75, -2);
  camera.wheelPrecision = 20;
  camera.minZ = 0;
  camera.attachControl(true);

  //Function to convert the coordinates of the simulated pitches into Babylon Vector3 coordinates
  function pitchToPoints(pitch) {
    const pitchTrajectory = pitch.pitch_trajectory;
    let points = pitchTrajectory.map(d => new BABYLON.Vector3(d.x / 3.281,  //Convert feet to meters
                                      d.z / 3.281,      //y and z are flipped in the data
                                      d.y / 3.281));
    points.pop();   //Remove the last point in our dataset since the simulation keeps going for one timestep beyond the homeplate
    return points;
  }

  //Calculate the timings needed to stagger the animation of each pitch based on their duration
  let timings = [];
  let acc = 500;  //Accumulator to determine time between pitches. Start with a fixed value to introduce a delay in animation on page load
  data.forEach(d => {
    const pitchTrajectory = d.pitch_trajectory;
    const duration = pitchTrajectory[pitchTrajectory.length - 1].time * 1000; //Convert seconds to milliseconds
    const offset = 100; //Constant temporal offset between pitch animations
    timings.push({ duration: duration, delay: acc});
    acc += duration + offset;
  });

  //Create a D3 scale for color, using Anu helper functions map scale outputs to Color3 objects based on the 'schemecategory10' palette from D3
  let scaleC = d3.scaleOrdinal(anu.ordinalChromatic('d310').toColor3());

  //Create a plane Mesh that will serve as our tooltip
  const hoverPlane = anu.create('plane', 'hoverPlane', { width: 1, height: 1 });
  hoverPlane.isPickable = false;    //Disable picking so it doesn't get in the way of interactions
  hoverPlane.renderingGroupId = 1;  //Set render id higher so it always renders in front of other objects
  hoverPlane.isVisible = false;     //Hide the tooltip
  hoverPlane.billboardMode = 7;     //Set the tooltip to always face the camera

  //Add an AdvancedDynamicTexture to this plane Mesh which will let us render Babylon GUI elements on it
  let advancedTexture = GUI.AdvancedDynamicTexture.CreateForMesh(hoverPlane);

  //Create and customize the rectangle for the background
  let UIBackground = new GUI.Rectangle();
  UIBackground.adaptWidthToChildren = true;
  UIBackground.adaptHeightToChildren = true;
  UIBackground.cornerRadius = 10;
  UIBackground.color = 'Black';
  UIBackground.thickness = 2;
  UIBackground.background = 'White';
  advancedTexture.addControl(UIBackground);

  //Create and customize the text for our tooltip
  let label = new GUI.TextBlock();
  label.paddingLeftInPixels = 25;
  label.paddingRightInPixels = 25;
  label.fontSizeInPixels = 40;
  label.resizeToFit = true;
  label.text = ' ';
  UIBackground.addControl(label);

  //Create a Center of Transform TransformNode that serves the parent node for all our meshes that make up our chart
  let CoT = anu.create('cot', 'cot');
  //Select our CoT so that we have it as a Selection object
  let chart = anu.selectName('cot', scene);

  //Create lines for each pitch's trajectory
  let trajectories = chart.bind('greasedLine', { meshOptions:  {points: (d) => pitchToPoints(d) },
                                                 materialOptions: {
                                                  width: 0.02,
                                                  visibility: 0, //This will cause the line to shrink and become fully invisible
                                                  color: (d) => scaleC(d.pitch_name)
                                                 }
                                               },
                                               data)
                          .run((d,n,i) => {
                            //We want to have transparency for greasedLines to facilitate hover highlighting, but unfortunately Babylon does not support this easily as of now
                            //A workaround is to assign a 1x1 texture with an alpha value and toggle its alpha on and off. The greasedLineMaterial.color will override the texture's rgb values so we just sent these to 0
                            const texture = new BABYLON.RawTexture(new Uint8Array([0, 0, 0, 50]), 1, 1, BABYLON.Engine.TEXTUREFORMAT_RGBA, scene, false, false, BABYLON.Engine.TEXTURE_LINEAR_LINEAR);
                            n.material.diffuseTexture = texture;
                            n.material.diffuseTexture.hasAlpha = false;     //We will set these two flags to true once we want transparency
                            n.material.useAlphaFromDiffuseTexture = false;
                          });
  
  //Start an animation for the pitch trajectories
  trajectories.transition((d,n,i) => ({
                duration: timings[i].duration,    //This will cause the animation for each pitch to be the same duration as the actual pitch itself (the data is simulated at 200 Hz)
                delay: timings[i].delay           //This will achieve our stagger effect
              }))
              .prop('greasedLineMaterial.visibility', 1)  //As t increases from 0 to 1, the line will progressively reveal itself from start to end, achieving our growing line effect

  //Create sphere meshes for each pitch to simulate baseballs
  let baseballs = chart.bind('sphere', { diameter: 0.075, segments: 8 }, data)
                       .prop('isVisible', false)  //Start hidden
                       .material((d,n,i) => new BABYLON.StandardMaterial('ballMaterial'))
                       .diffuseColor((d,n,i) => scaleC(d.pitch_name))
                       .specularColor(BABYLON.Color3.Black())  //Remove reflections
                       .action((d,n,i) => new BABYLON.ExecuteCodeAction(        //When the pointer over event happens, show and update the tooltip, and
                         BABYLON.ActionManager.OnPointerOverTrigger,            //reduce the alpha of all other spheres and trajectories
                         () => {
                           hoverPlane.isVisible = true;
                           label.text = `${d.pitch_name}\n${d.release_speed} mph\n${d.type}`;
                           hoverPlane.position = n.position.add(new BABYLON.Vector3(0, 0.1, 0));
                           
                           baseballs.prop('material.alpha', (_d, _n, _i) => (_i !== i) ? 0.4 : 1);
                           trajectories.run((d_,n_,i_) => {
                             if (i_ !== i) {
                               n_.material.diffuseTexture.hasAlpha = true;    //Here we set these two flags to true to enable transparency on greasedLines
                               n_.material.useAlphaFromDiffuseTexture = true;
                             }
                           });
                         }
                       ))
                       .action((d,n,i) => new BABYLON.ExecuteCodeAction(        //When the pointer over event happens, hide the tooltip, and
                         BABYLON.ActionManager.OnPointerOutTrigger,             //reset the alpha of all spheres and trajectories
                         () => {
                           hoverPlane.isVisible = false;
                           
                           baseballs.prop('material.alpha', 1);
                           trajectories.run((_d,_n,_i) => {
                             if (_i !== i) {
                               _n.material.diffuseTexture.hasAlpha = false;
                               _n.material.useAlphaFromDiffuseTexture = false;
                             }
                           });
                         }
                       ));

  //Start an animation for the baseball pitches
  baseballs.transition((d,n,i) => ({
              duration: timings[i].duration,
              delay: timings[i].delay - 50, //Make sure that the ball moves before the line to create a nice trail effect
            }))
            .tween((d,n,i) => {
              const trajectory = pitchToPoints(d);
              n.position = trajectory[0]; //Start position
              trajectory[trajectory.length - 1] = new BABYLON.Vector3(d.plate_x / 3.281, d.plate_z / 3.281, 0); //Replace the last position with the recorded coordinates of where the ball actually was when it crosses the home plate
              
              return (t) => {
                if (t > 0)
                  n.isVisible = true;

                //In our array of Vector3, calculate between which pitch segments the animation is currently at, compute the intermediary t value, then Lerp it for a smooth animation
                const idx = Math.floor(t * (trajectory.length - 1));
                const nextIdx = Math.min(idx + 1, trajectory.length - 1);
                const thisT = t * (trajectory.length - 1) - idx;
                n.position = BABYLON.Vector3.Lerp(trajectory[idx], trajectory[nextIdx], thisT);
              }
            });

    
  //Create a simple baseball environment
  let environmentCoT = anu.create('cot', 'environment');
  let environment = anu.selectName('environment', scene);

  //Shared materials
  let sandMaterial = new BABYLON.StandardMaterial('sandMaterial');
  sandMaterial.diffuseColor = BABYLON.Color3.FromHexString('#CAA472');
  sandMaterial.specularColor = BABYLON.Color3.Black();
  let whiteMaterial = new BABYLON.StandardMaterial('rubberMaterial');
  whiteMaterial.diffuseColor = BABYLON.Color3.White();
  whiteMaterial.specularColor = BABYLON.Color3.Black();

  //Grass
  environment.bind('ground', { width: 27.4, height: 27.4 })
    .material(new BABYLON.StandardMaterial('groundMaterial'))
    .diffuseColor(BABYLON.Color3.FromHexString('#3f9b0b'))
    .specularColor(BABYLON.Color3.Black())
    .position(new BABYLON.Vector3(0, 0, 18.4))
    .rotationY(Math.PI / 4);

  //Pitcher's mound
  environment.bind('disc', { radius: 2.7432 })
    .material(sandMaterial)
    .position(new BABYLON.Vector3(0, 0.01, 18.4))
    .rotationX(Math.PI / 2);

  //Pitching rubber
  environment.bind('ground', { width: 1.524, height: 0.9144 })
    .material(whiteMaterial)
    .position(new BABYLON.Vector3(0, 0.02, 18.4 + 0.4572));

  //Sand around home plate
  environment.bind('disc', { radius: 3.9624 })
    .material(sandMaterial)
    .position(new BABYLON.Vector3(0, 0.01, 0))
    .rotationX(Math.PI / 2);

  //Home plate
  const homePlateVertices = [     //Because it is an irregular polygon, we use the PolygonMeshBuilder with custom vertices using the real world sizes of the home plate
    new BABYLON.Vector2(0, 0),
    new BABYLON.Vector2(0.2159, 0),
    new BABYLON.Vector2(0.2159, -0.2152),
    new BABYLON.Vector2(0, -0.4311),
    new BABYLON.Vector2(-0.2159, -0.2152),
    new BABYLON.Vector2(-0.2159, 0)
  ];
  let plateBuilder = new BABYLON.PolygonMeshBuilder('plateMesh', homePlateVertices, scene, earcut);   //earcut package is required as a dependency
  let plateMesh = plateBuilder.build();
  plateMesh.parent = environmentCoT;
  plateMesh = new anu.Selection([plateMesh]);
  plateMesh.material(whiteMaterial)
    .positionY(0.02);

  //Strike zone
  const strikeZoneCorners = [ 
    new BABYLON.Vector2(0.2667,0),
    new BABYLON.Vector2(0.2667,0.7551),
    new BABYLON.Vector2(-0.2667, 0.7551),
    new BABYLON.Vector2(-0.2667,0),
  ];
  const strikeZoneHole = [
    new BABYLON.Vector2(0, 0.0508),
    new BABYLON.Vector2(0.2159, 0.0508),
    new BABYLON.Vector2(0.2159, 0.7043),
    new BABYLON.Vector2(-0.2159, 0.7043),
    new BABYLON.Vector2(-0.2159, 0.0508)
  ];
  let zoneBuilder = new BABYLON.PolygonMeshBuilder('strikeZoneMesh', strikeZoneCorners, scene, earcut);
  zoneBuilder.addHole(strikeZoneHole);
  let strikeZoneMesh = zoneBuilder.build(false, 0.005);
  strikeZoneMesh.parent = environmentCoT;
  strikeZoneMesh.material = whiteMaterial;
  strikeZoneMesh.position.y = 0.4;
  strikeZoneMesh.rotation.x = -Math.PI / 2;

  return scene;
}