JavaScript 3D Model Viewer

JavaScript 3D Model Viewer
Project: 3D Model Viewer
Author: Caleb Miller
Edit Online: View on CodePen
License: MIT

JavaScript 3D Model Viewer is a captivating code that presents a powerful solution for visualizing 3D models directly in a web browser using JavaScript. The code demonstrates a method to render 3D models with interactive controls, allowing users to rotate, zoom, and pan the model effortlessly.

By leveraging JavaScript and WebGL technology, this code offers a seamless and immersive experience for viewing complex 3D objects, making it an invaluable tool for developers, designers, and anyone seeking to showcase 3D models online without the need for external software installations.

How to Create JavaScript 3D Model Viewer

1. Create the HTML structure for 3D modal viewer as follows:

<div class="controls">
	<select class="model-select"></select>
	<select class="material-select"></select>
	<button type="button" class="normal-toggle"></button>
</div>

2. Style the basic interface of 3D modal viewer using the following CSS code:

body {
  background-color: black;
}

.controls {
  position: fixed;
  top: 0;
  left: 6px;
  width: 132px;
}

select,
button {
  display: block;
  padding: 6px 10px;
  margin-top: 6px;
  width: 100%;
  border: none;
  outline: none;
  background-color: #555;
  color: #fff;
  font-family: sans-serif;
  font-size: 12px;
  letter-spacing: 0.1em;
  line-height: 1.4;
  text-align: center;
  text-transform: uppercase;
  opacity: 0.8;
  -webkit-user-select: none;
     -moz-user-select: none;
      -ms-user-select: none;
          user-select: none;
  transition: opacity 0.2s;
}
select:hover, select:active, select:focus,
button:hover,
button:active,
button:focus {
  opacity: 1;
}

.render-time {
  position: fixed;
  bottom: 0;
  padding: 6px;
  opacity: 0.75;
  color: white;
  font-family: monospace;
  font-size: 10px;
  line-height: 1.3;
  -webkit-user-select: none;
     -moz-user-select: none;
      -ms-user-select: none;
          user-select: none;
  white-space: pre;
  pointer-events: none;
}

3. Now, load the Simple Stage JS by adding the following CDN link before closing the body tag:

<!-- Simple Stage JS -->
<script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/Simple-Stage%400.1.0.js'></script>

4. Finally, add the following JavaScript code to your project. It has some built in 3D modals to view. Additionally, you have the flexibility to define your own custom modals to showcase within the viewer.

console.clear();

// Config
// ------------------------

const cameraDistance = 12;
const sceneScale = 1;

//   World Orientation
// ---------------------
//          -y  -z
//           | /
//     ______|/_____
//     -x   /|    +x
//         / |
//       +z  +y

// Lightweight canvas adapter. All drawing code is still native 2D canvas commands.
// const stage = new SimpleStage({ container: document.body });

var demo = document.querySelector('.cd__main');
const stage = new SimpleStage({ container: demo });

// Constants
// ----------------------

const TAU = Math.PI * 2;
// Material keys & values are equal. Using an object just provides a namespace.
const MATERIALS = {
	PLASTIC: 'PLASTIC',
	PEARL: 'PEARL',
	CHROME: 'CHROME',
	NORMALS: 'NORMALS'
};


// State
// ----------------------

let selectedModel = 'bumpify';
let selectedMaterial = MATERIALS.PLASTIC;
let drawNormalLines = false;
// Animate rotation along 2 axes, Y and Z.
let rotationAutoY = 0;
let rotationAutoZ = 0;
// The rendered rotation (note: interactive rotation is tracked further down).
let rotationFinalY = 0;
let rotationFinalZ = 0;



// Helpers
// ----------------------

const lerp = (a, b, x) => (b - a) * x + a;

// Clone array and all vertices.
function cloneVertices(vertices) {
	return vertices.map(vertex => ({ ...vertex }));
}

function computeEdgeMiddle(v1, v2) {
	return {
		x: (v1.x + v2.x) * 0.5,
		y: (v1.y + v2.y) * 0.5,
		z: (v1.z + v2.z) * 0.5
	};
}

// Compute triangle midpoint.
// Mutates `middle` property of given `poly`.
function computeTriMiddle(poly) {
	const v = poly.vertices;
	poly.middle.x = (v[0].x + v[1].x + v[2].x) / 3;
	poly.middle.y = (v[0].y + v[1].y + v[2].y) / 3;
	poly.middle.z = (v[0].z + v[1].z + v[2].z) / 3;
}

// Compute quad midpoint.
// Mutates `middle` property of given `poly`.
function computeQuadMiddle(poly) {
	const v = poly.vertices;
	poly.middle.x = (v[0].x + v[1].x + v[2].x + v[3].x) / 4;
	poly.middle.y = (v[0].y + v[1].y + v[2].y + v[3].y) / 4;
	poly.middle.z = (v[0].z + v[1].z + v[2].z + v[3].z) / 4;
}

function computePolyMiddle(poly) {
	if (poly.vertices.length === 3) {
		computeTriMiddle(poly);
	} else {
		computeQuadMiddle(poly);
	}
}

// Compute distance from any polygon (tri or quad) midpoint to camera.
// Sets `depth` property of given `poly`.
// Also triggers midpoint calculation, which mutates `middle` property of `poly`.
function computePolyDepth(poly) {
	computePolyMiddle(poly);
	const dX = poly.middle.x;
	const dY = poly.middle.y;
	const dZ = poly.middle.z - cameraDistance;
	poly.depth = Math.sqrt(dX*dX + dY*dY + dZ*dZ);
}

// Compute normal of any polygon. Uses normalized vector cross product.
// Mutates `normalName` property of given `poly`.
function computePolyNormal(poly, normalName) {
	// Store quick refs to vertices
	const v1 = poly.vertices[0];
	const v2 = poly.vertices[1];
	const v3 = poly.vertices[2];
	// Calculate difference of vertices, following winding order.
	const ax = v1.x - v2.x;
	const ay = v1.y - v2.y;
	const az = v1.z - v2.z;
	const bx = v1.x - v3.x;
	const by = v1.y - v3.y;
	const bz = v1.z - v3.z;
	// Cross product
	const nx = ay*bz - az*by;
	const ny = az*bx - ax*bz;
	const nz = ax*by - ay*bx;
	// Compute magnitude of normal and normalize
	const mag = Math.sqrt(nx*nx + ny*ny + nz*nz);
	const polyNormal = poly[normalName];
	polyNormal.x = nx / mag;
	polyNormal.y = ny / mag;
	polyNormal.z = nz / mag;
}


// Define models once. The origin is the center of the model.
// A plane, arbitrarily subdivided.
function makePlaneModel({ edgeVertCount=2, hue=0, vOffset=0 }) {
	const vertices = [];
	const polys = [];

	const width = 2;
	const startPos = width / -2;
	const inc = width / (edgeVertCount - 1);
	const maxDistance = Math.sqrt(startPos*startPos + startPos*startPos);
	
	for (let x=0; x<edgeVertCount; x++) {
		for (let y=0; y<edgeVertCount; y++) {
			const xPos = x*inc + startPos;
			const zPos = y*inc + startPos;
			
			vertices.push({
				x: xPos,
				y: -1,
				z: zPos,
				distance: Math.sqrt(xPos*xPos + zPos*zPos) / maxDistance
			});
			
			if (x > 0 && y > 0) {
				const currentIndex = x*edgeVertCount + y;
				polys.push({
					vIndexes: [
						currentIndex - edgeVertCount + vOffset,
						currentIndex - edgeVertCount - 1 + vOffset,
						currentIndex - 1 + vOffset
					],
					color: { h: hue, s: 80, l: 50 }
				});
				polys.push({
					vIndexes: [
						currentIndex - edgeVertCount + vOffset,
						currentIndex - 1 + vOffset,
						currentIndex + vOffset
					],
					color: { h: hue, s: 80, l: 50 }
				});
			}
		}
	}
	
	return { vertices, polys };
};
// A cube, arbitrarily subdivided.
function makeCubeModel(edgeVertCount=2) {
	const vCount = edgeVertCount * edgeVertCount;
	const planeTop = makePlaneModel({ edgeVertCount, hue: 0, vOffset: 0 });
	const planeBottom = makePlaneModel({ edgeVertCount, hue: 60, vOffset: vCount });
	const planeLeft = makePlaneModel({ edgeVertCount, hue: 120, vOffset: vCount*2 });
	const planeRight = makePlaneModel({ edgeVertCount, hue: 180, vOffset: vCount*3 });
	const planeFront = makePlaneModel({ edgeVertCount, hue: 240, vOffset: vCount*4 });
	const planeBack = makePlaneModel({ edgeVertCount, hue: 300, vOffset: vCount*5 });
	
	planeBottom.vertices.forEach(v => {
		v.x *= -1;
		v.y = 1;
	});
	planeLeft.vertices.forEach(v => {
		v.y = -v.x;
		v.x = -1;		
	});
	planeRight.vertices.forEach(v => {
		v.y = v.x;
		v.x = 1;		
	});
	planeFront.vertices.forEach(v => {
		v.y = v.z;
		v.z = 1;		
	});
	planeBack.vertices.forEach(v => {
		v.y = -v.z;
		v.z = -1;
	});
	
	return {
		vertices: [
			...planeTop.vertices,
			...planeBottom.vertices,
			...planeLeft.vertices,
			...planeRight.vertices,
			...planeFront.vertices,
			...planeBack.vertices
		],
		polys: [
			...planeTop.polys,
			...planeBottom.polys,
			...planeLeft.polys,
			...planeRight.polys,
			...planeFront.polys,
			...planeBack.polys
		]
	};
}

const simpleCubeModel = {
	vertices: [
		// top
		{ x: -1, y: -1, z: 1 },
		{ x:  1, y: -1, z: 1 },
		{ x:  1, y:  1, z: 1 },
		{ x: -1, y:  1, z: 1 },
		// bottom
		{ x: -1, y: -1, z: -1 },
		{ x:  1, y: -1, z: -1 },
		{ x:  1, y:  1, z: -1 },
		{ x: -1, y:  1, z: -1 }
	],
	polys: [
		// z = 1
		{
			vIndexes: [0, 1, 2, 3],
			color: { h: 0, s: 80, l: 50 }
		},
		// z = -1
		{
			vIndexes: [7, 6, 5, 4],
			color: { h: 60, s: 80, l: 50 }
		},
		// y = 1
		{
			vIndexes: [3, 2, 6, 7],
			color: { h: 120, s: 80, l: 50 }
		},
		// y = -1
		{
			vIndexes: [4, 5, 1, 0],
			color: { h: 180, s: 80, l: 50 }
		},
		// x = 1
		{
			vIndexes: [5, 6, 2, 1],
			color: { h: 240, s: 80, l: 50 }
		},
		// x = -1
		{
			vIndexes: [0, 3, 7, 4],
			color: { h: 300, s: 80, l: 50 }
		}
	]
};

const makeTetrahedronModel = () => {
	const vBase1 = { x: Math.sin(0), y: 0, z: Math.cos(0) };
	const vBase2 = { x: Math.sin(TAU / 3), y: 0, z: Math.cos(TAU / 3) };
	const vBase3 = { x: Math.sin(TAU / 3 * 2), y: 0, z: Math.cos(TAU / 3 * 2) };
	const edgeLen = Math.sqrt((vBase2.x - vBase1.x) ** 2 + (vBase2.z - vBase1.z) ** 2);
	const height = Math.sqrt(2/3) * edgeLen;
	const vTop = { x: 0, y: -height, z: 0 };
	
	// Simplified Version
	// const sin = Math.sin(TAU / 3);
	// const vBase1 = { x: 0, y: 0, z: 1 };
	// const vBase2 = { x: sin, y: 0, z: -0.5 };
	// const vBase3 = { x: -sin, y: 0, z: -0.5 };
	// const edgeLen = sin * 2;
	// const height = Math.sqrt(2/3) * edgeLen;
	// const vTop = { x: 0, y: -height, z: 0 };
	
	const vertices = [vTop, vBase1, vBase2, vBase3];
	
	vertices.forEach(v => v.y += height * 0.25);
	
	return {
		vertices,
		polys: [
			{
				vIndexes: [2, 1, 0],
				color: { h: 192, s: 80, l: 50 }
			},
			{
				vIndexes: [3, 2, 0],
				color: { h: 192, s: 80, l: 50 }
			},
			{
				vIndexes: [1, 3, 0],
				color: { h: 192, s: 80, l: 50 }
			},
			{
				vIndexes: [1, 2, 3],
				color: { h: 192, s: 80, l: 50 }
			}
		]
	};
};

const makeUvSphereModel = (props) => {
	const {
		triangles=false,
		latCount,
		longCount,
		radius,
		color
	} = props;
	
	const vertices = [];
	const polys = [];
	// Generate in rings and append to flat array.
	for (let i=0; i<latCount; i++) {
		const ringPos = i / (latCount - 1) * Math.PI;
		const ringRadius = Math.sin(ringPos) * radius;
		const z = Math.cos(ringPos) * radius;
		// Poles don't need a full ring.
		// These become the first and last vertices in the array.
		if (z === 1 || z === -1) {
			vertices.push({ x: 0, y: 0, z});
		} else {
			for (let ii=0; ii<longCount; ii++) {
				const angle = ii / longCount * TAU;
				const x = Math.sin(angle) * ringRadius;
				const y = Math.cos(angle) * ringRadius;
				vertices.push({ x, y, z });
			}
		}
	}
	
	for (let i=0; i<longCount; i++) {
		polys.push({
			vIndexes: [
				(i + 1) % longCount + 1,
				i + 1,
				0
			],
			color: color
		});
		const lastRingStart = vertices.length - longCount - 1;
		polys.push({
			vIndexes: [
				vertices.length - 1,
				lastRingStart + i,
				lastRingStart + ((i + 1) % longCount)
			],
			color: color
		});
	}
	
	if (triangles) {
		for (let i=1; i<vertices.length-longCount-1; i++) {
			const index = i - 1;
			const latIndex = Math.floor(index / longCount);
			const latStartIndex = latIndex * longCount;
			const longIndex = index % longCount;
			const latNeighbor = (longIndex + 1) % longCount + latStartIndex;
			polys.push({
				vIndexes: [
					index + 1,
					latNeighbor + 1,
					latNeighbor + longCount + 1
				],
				color: color
			});
			polys.push({
				vIndexes: [
					latNeighbor + longCount + 1,
					index + longCount + 1,
					index + 1
				],
				color: color
			});
		}
	} else {
		for (let i=1; i<vertices.length-longCount-1; i++) {
			const index = i - 1;
			const latIndex = Math.floor(index / longCount);
			const latStartIndex = latIndex * longCount;
			const longIndex = index % longCount;
			const latNeighbor = (longIndex + 1) % longCount + latStartIndex;
			polys.push({
				vIndexes: [
					index + 1,
					latNeighbor + 1,
					latNeighbor + longCount + 1,
					index + longCount + 1
				],
				color: color
			});
		}
	}
	
	return { vertices, polys };
};

// Icosphere implentation borrowed from
// http://blog.andreaskahler.com/2009/06/creating-icosphere-mesh-in-code.html
const makeIcosphereModel = (props) => {
	const {
		radius = 1,
		subdivisions = 0,
		color = { h: 0, s: 0, l: 50 }
	} = props;
	
	// Create 12 vertices of an icosahedron
	const t = (1 + Math.sqrt(5)) / 2;
	const vertices = [
		{ x: -1, y:  t, z:  0 },
		{ x:  1, y:  t, z:  0 },
		{ x: -1, y: -t, z:  0 },
		{ x:  1, y: -t, z:  0 },

		{ x:  0, y: -1, z:  t },
		{ x:  0, y:  1, z:  t },
		{ x:  0, y: -1, z: -t },
		{ x:  0, y:  1, z: -t },

		{ x:  t, y:  0, z: -1 },
		{ x:  t, y:  0, z:  1 },
		{ x: -t, y:  0, z: -1 },
		{ x: -t, y:  0, z:  1 },
	];
	
	let tris = [
		// 5 faces around point 0
		[0, 11, 5],
		[0, 5, 1],
		[0, 1, 7],
		[0, 7, 10],
		[0, 10, 11],

		// 5 adjacent faces
		[1, 5, 9],
		[5, 11, 4],
		[11, 10, 2],
		[10, 7, 6],
		[7, 1, 8],

		// 5 faces around point 3
		[3, 9, 4],
		[3, 4, 2],
		[3, 2, 6],
		[3, 6, 8],
		[3, 8, 9],

		// 5 adjacent faces
		[4, 9, 5],
		[2, 4, 11],
		[6, 2, 10],
		[8, 6, 7],
		[9, 8, 1]
	];
	
	// Created icosphere is not a unit sphere. Scale it to unit sphere dimensions.
	const v0 = vertices[0];
	const mag = Math.sqrt(v0.x*v0.x + v0.y*v0.y + v0.z*v0.z);
	vertices.forEach(v => {
		v.x /= mag;
		v.y /= mag;
		v.z /= mag;
	});
	
	if (subdivisions > 0) {
		const cache = [];
		const getMiddlePointIndex = (v1, v2) => {
			const hit = cache.find(c => (
				c.v1 === v1 && c.v2 === v2 || c.v1 === v2 && c.v2 === v1
			));
			if (hit) {
				return hit.vIndex;
			}
			const mid = computeEdgeMiddle(v1, v2);
			const mag = Math.sqrt(mid.x*mid.x + mid.y*mid.y + mid.z*mid.z);
			mid.x /= mag;
			mid.y /= mag;
			mid.z /= mag;
			
			const vIndex = vertices.length;
			vertices.push(mid);
			cache.push({ v1, v2, vIndex });
			
			return vIndex;
		}
		
		for (let i=0; i<subdivisions; i++) {
			const tris2 = [];
			tris.forEach(t => {
				const vi0 = t[0];
				const vi1 = t[1];
				const vi2 = t[2];
				const v0 = vertices[vi0];
				const v1 = vertices[vi1];
				const v2 = vertices[vi2];
				
				// Replace triangle by 4 triangles.
				const a = getMiddlePointIndex(v0, v1);
				const b = getMiddlePointIndex(v1, v2);
				const c = getMiddlePointIndex(v2, v0);
				
				tris2.push([vi0, a, c]);
				tris2.push([vi1, b, a]);
				tris2.push([vi2, c, b]);
				tris2.push([a, b, c]);
			});
			tris = tris2;
		}
	}
	
	// Set custom radius if needed.
	if (radius !== 1) {
		vertices.forEach(v => {
			v.x *= radius;
			v.y *= radius;
			v.z *= radius;
		});
	}
	
	// Generate full polys from triangle indexes.
	const polys = tris.map(t => ({
		vIndexes: t,
		color: color
	}));
	
	return { vertices, polys };
};

const makeTorusModel = (props) => {
	const {
		triangles = false,
		// "Lat" = small rings
		// "Long" = longitudal lines
		latCount = 40,
		longCount = 20,
		innerRadius = .6,
		outerRadius = 1,
		color = { h: 192, s: 0, l: 50 }
	} = props;
	
	const vertices = [];
	
	const latArc = TAU / latCount;
	const longArc = TAU / longCount;
	for (let x=0; x<latCount; x++) {
		const latAngle = latArc * x;
		const sinY = Math.sin(latAngle);
		const cosY = Math.cos(latAngle);
		for (let y=0; y<longCount; y++) {
			const longAngle = longArc * y;
			const vx = Math.sin(longAngle) * innerRadius + outerRadius;
			const vy = Math.cos(longAngle) * innerRadius;
			const vz = 0;
			vertices.push({
				x: vx*cosY - vz*sinY,
				y: vy,
				z: vx*sinY + vz*cosY
			});
		}
	}
	
	let polys;
	if (triangles) {
		polys = [];
		vertices.forEach((v, i) => {
			const latIndex = Math.floor(i / longCount);
			const latStartIndex = latIndex * longCount;
			const longIndex = i % longCount;
			const latNeighbor = (longIndex + 1) % longCount + latStartIndex;
			const wrapTorus = i => i % vertices.length;
			const v1 = wrapTorus(i + longCount);
			const v2 = wrapTorus(latNeighbor + longCount);
			const v3 = wrapTorus(latNeighbor);
			const v4 = i;
			polys.push({
				vIndexes: [v1, v2, v3],
				color: color
			});
			polys.push({
				vIndexes: [v3, v4, v1],
				color: color
			});
		});
	} else {
		polys = vertices.map((v, i) => {
			const latIndex = Math.floor(i / longCount);
			const latStartIndex = latIndex * longCount;
			const longIndex = i % longCount;
			const latNeighbor = (longIndex + 1) % longCount + latStartIndex;
			const wrapTorus = i => i % vertices.length;
			return {
				vIndexes: [
					wrapTorus(i + longCount),
					wrapTorus(latNeighbor + longCount),
					wrapTorus(latNeighbor),
					i
				],
				color: color
			};
		});
	}
	
	return { vertices, polys };
};


class Entity {
	constructor({ model, x=0, y=0, z=0, rotateX=0, rotateY=0, rotateZ=0, scaleX=1, scaleY=1, scaleZ=1 }) {
		const vertices = cloneVertices(model.vertices);
		
		const polys = model.polys.map(p => ({
			vertices: p.vIndexes.map(vIndex => vertices[vIndex]),
			color: p.color,
			depth: 0,
			middle: { x: 0, y: 0, z: 0 },
			normalWorld: { x: 0, y: 0, z: 0 },
			normalCamera: { x: 0, y: 0, z: 0 },
			normalModel: { x: 0, y: 0, z: 0 }
		}));
		
		this.model = model;
		this.x = x;
		this.y = y;
		this.z = z;
		this.rotateX = rotateX;
		this.rotateY = rotateY;
		this.rotateZ = rotateZ;
		this.scaleX = scaleX;
		this.scaleY = scaleY;
		this.scaleZ = scaleZ;
		this.vertices = vertices;
		this.polys = polys;
	}
	
	transform() {
		transformVertices(
			this.model.vertices,
			this.vertices,
			this.x,
			this.y,
			this.z,
			this.rotateX,
			this.rotateY,
			this.rotateZ,
			this.scaleX,
			this.scaleY,
			this.scaleZ
		);
	}
}


function applyFlowerBoxScale(model, scale) {
	model.vertices.forEach(v => {
		const weight = v.distance ** 1.25;
		v.x = lerp(v.x, v.x * scale, weight);
		v.y = lerp(v.y, v.y * scale, weight);
		v.z = lerp(v.z, v.z * scale, weight);
	});
	return model;
}

function applyRandomize(entity, scale) {
	entity.polys.forEach(p => {
		computePolyNormal(p, 'normalModel');
		const normal = p.normalModel;
		p.vertices.forEach(v => {
			if (v.hasOwnProperty('randomized')) return;
			v.randomized = true;
			v.x += (Math.random() * 2 - 1) * scale * normal.x;
			v.y += (Math.random() * 2 - 1) * scale * normal.y;
			v.z += (Math.random() * 2 - 1) * scale * normal.z;
		});
	});
	entity.model.vertices = cloneVertices(entity.vertices);
	return entity;
}

function ringifySphereModel({ model, freq, height, base }) {
	const map = x => Math.abs(Math.sin(Math.asin(x) * freq));
	model.vertices.forEach(v => {
		const m = map(v.z) * height + base;
		v.x *= m;
		v.y *= m;
		v.z *= m;
	});
	return model;
}

function bumpifySphereModel({ model, freq, height, base }) {
	const map = x => Math.abs(Math.sin(Math.asin(x) * freq));
	model.vertices.forEach(v => {
		const m = (map(v.x) + map(v.y) + map(v.z)) * height * 0.6667 + base;
		v.x *= m;
		v.y *= m;
		v.z *= m;
	});
	return model;
}


const entities = {
	cube: new Entity({ model: simpleCubeModel }),
	'cube (1.25)': new Entity({
		model: applyFlowerBoxScale(makeCubeModel(16), 1.25)
	}),
	'cube (-0.12)': new Entity({
		model: applyFlowerBoxScale(makeCubeModel(16), -0.12)
	}),
	'cube (-1)': new Entity({
		model: applyFlowerBoxScale(makeCubeModel(16), -1)
	}),
	tetrahedron: new Entity({ model: makeTetrahedronModel() }),
	'icosphere (1)': new Entity({
		model: makeIcosphereModel({
			subdivisions: 1,
			color: { h: 192, s: 80, l: 50 }
		})
	}),
	'icosphere (2)': new Entity({
		model: makeIcosphereModel({
			subdivisions: 2,
			color: { h: 192, s: 80, l: 50 }
		})
	}),
	'icosphere (3)': new Entity({
		model: makeIcosphereModel({
			subdivisions: 3,
			color: { h: 192, s: 80, l: 50 }
		})
	}),
	'icosphere (4)': new Entity({
		model: makeIcosphereModel({
			subdivisions: 4,
			color: { h: 192, s: 80, l: 50 }
		})
	}),
	'UV sphere': new Entity({
		model: makeUvSphereModel({
			latCount: 17,
			longCount: 20,
			radius: 1,
			color: { h: 192, s: 80, l: 50 }
		})
	}),
	torus: new Entity({
		model: makeTorusModel({
			latCount: 36,
			longCount: 20,
			innerRadius: 0.6,
			outerRadius: 1.25,
			color: { h: 192, s: 80, l: 50 }
		})
	}),
	'torus (high)': new Entity({
		model: makeTorusModel({
			latCount: 72,
			longCount: 40,
			innerRadius: 0.6,
			outerRadius: 1.25,
			color: { h: 192, s: 80, l: 50 }
		})
	}),
	randomize: applyRandomize(
		new Entity({
			model: makeIcosphereModel({
				subdivisions: 3,
				color: { h: 192, s: 80, l: 50 }
			})
		}),
		0.16
	),
	ringify: new Entity({
		model: ringifySphereModel({
			model: makeUvSphereModel({
				latCount: 160,
				longCount: 5,
				radius: 1,
				color: { h: 192, s: 80, l: 50 }
			}),
			freq: 24,
			height: .1,
			base: 1.6
		}),
	}),
	bumpify: new Entity({
		model: bumpifySphereModel({
			model: makeIcosphereModel({
				subdivisions: 3,
				color: { h: 192, s: 80, l: 50 }
			}),
			freq: 6,
			height: 0.2,
			base: 1
		}),
	}),
	'bumpify (high)': new Entity({
		model: bumpifySphereModel({
			model: makeIcosphereModel({
				subdivisions: 4,
				color: { h: 192, s: 80, l: 50 }
			}),
			freq: 6,
			height: 0.2,
			base: 1
		}),
	})
};

const getActiveVertices = () => entities[selectedModel].vertices;
const getActivePolys = () => entities[selectedModel].polys;

// Compute model normals once
Object.values(entities).forEach(entity => {
	entity.polys.forEach(p => {
		computePolyNormal(p, 'normalModel');
	});
});


// Apply translation/rotation to all vertices in scene.
function transformVertices(vertices, target, tX, tY, tZ, rX, rY, rZ, sX, sY, sZ) {
	// Matrix multiplcation constants only need calculated once for all vertices.
	const sinX = Math.sin(rX);
	const cosX = Math.cos(rX);
	const sinY = Math.sin(rY);
	const cosY = Math.cos(rY);
	const sinZ = Math.sin(rZ);
	const cosZ = Math.cos(rZ);
	
	// Using forEach() like map(), but with a (recycled) target array.
	vertices.forEach((v, i) => {
		const targetVertex = target[i];
		// X axis rotation
		const x1 = v.x;
		const y1 = v.z*sinX + v.y*cosX;
		const z1 = v.z*cosX - v.y*sinX;
		// Y axis rotation
		const x2 = x1*cosY - z1*sinY;
		const y2 = y1;
		const z2 = x1*sinY + z1*cosY;
		// Z axis rotation
		const x3 = x2*cosZ - y2*sinZ;
		const y3 = x2*sinZ + y2*cosZ;
		const z3 = z2;
		
		// Scale, Translate, and set the transform.
		targetVertex.x = x3 * sX + tX;
		targetVertex.y = y3 * sY + tY;
		targetVertex.z = z3 * sZ + tZ;
	});
}

// Apply perspective projection to all vertices in scene.
function projectScene(vertices) {
	const focalLength = cameraDistance * sceneScale;
	vertices.forEach((v, i) => {
		const depth = focalLength / (cameraDistance - v.z);
		v.x = v.x * depth;
		v.y = v.y * depth;
	});
}


// Main loop (rAF)
stage.onTick = function tick({ simTime, simSpeed, width, height }) {
	const tickStartTime = performance.now();
	
	rotationFinalY = rotationAutoY + pointerDelta.x;
	rotationFinalZ = rotationAutoZ + pointerDelta.y;
	
	const activeEntity = entities[selectedModel];
	
	activeEntity.rotateX = rotationFinalZ;
	activeEntity.rotateY = -rotationFinalY;
	activeEntity.transform();
	
	const activeVertices = getActiveVertices();
	const activePolys = getActivePolys();
	
	activePolys.forEach(p => computePolyNormal(p, 'normalWorld'));
	transformVertices(activeVertices, activeVertices, 0, 0, 0, 0.75, 0, 0, 1, 1, 1);
	activePolys.forEach(computePolyDepth);
	activePolys.sort((a, b) => b.depth - a.depth);
	projectScene(activeVertices);
	activePolys.forEach(p => computePolyNormal(p, 'normalCamera'));
	
	updateTickTime(performance.now() - tickStartTime);
};

// Draw loop
let renderedPolyCount = 0;
stage.onDraw = function draw({ ctx, width, height }) {
	const renderStartTime = performance.now();
	
	const scale = Math.min(width, height) / 4;
	const centerX = width / 2;
	const centerY = height / 2;
	
	ctx.globalCompositeOperation = 'source-over';
	ctx.fillStyle = '#000';
	ctx.fillRect(0, 0, width, height);
	
	// Center coordinate system
	ctx.save();
	ctx.translate(centerX, centerY);
	ctx.scale(scale, scale);

	// ctx.strokeStyle = '#09f';
	// ctx.lineJoin = 'round';
	// ctx.lineWidth = 1 / scale;
	
	const activePolys = getActivePolys();
	
	renderedPolyCount = 0;
	
	const fillPoly = p => {
		renderedPolyCount++;
		const { vertices } = p;
		const lastV = vertices[vertices.length - 1];
		ctx.beginPath();
		ctx.moveTo(lastV.x, lastV.y);
		for (let v of vertices) {
			ctx.lineTo(v.x, v.y);
		}
		ctx.fill();
	};
	
	if (selectedMaterial === MATERIALS.PLASTIC) {
		activePolys.forEach(p => {
			if (p.normalCamera.z < 0) return;
			
			const hardness = 32;
			const normalLight = p.normalWorld.y;
			const normalReflection = 1 - p.normalCamera.z;
			const minLightness = normalReflection * 6 + 10;
			const lightnessRemainder = 100 - minLightness;
			const lightness = normalLight > 0
				? minLightness
				: ((normalLight ** hardness - normalLight) / 2) * lightnessRemainder + minLightness;
			ctx.fillStyle = `hsl(${p.color.h},${p.color.s}%,${lightness}%)`;
			
			fillPoly(p);
		});
	}
	else if (selectedMaterial === MATERIALS.PEARL) {
		activePolys.forEach(p => {
			if (p.normalCamera.z < 0) return;

			const hardness = 42;
			const normalLight = p.normalWorld.y;
			const normalReflection = (1 - p.normalCamera.z) ** 1.5;
			const minLightness = normalReflection * 56 + 10;
			const lightnessRemainder = 100 - minLightness;
			const lightness = normalLight > 0
				? minLightness
				: ((normalLight ** hardness - normalLight) / 2) * lightnessRemainder + minLightness;
			const hue = 180 - normalReflection * 240;
			ctx.fillStyle = `hsl(${hue},${p.color.s}%,${lightness}%)`;
			
			fillPoly(p);
		});
	}
	else if (selectedMaterial === MATERIALS.CHROME) {
		activePolys.forEach(p => {
			if (p.normalCamera.z < 0) return;
	
			const normalLight = p.normalWorld.y;
			const normalColor = p.normalCamera.y;
			const normalReflection = 1 - p.normalCamera.z;
			let hue;
			let lightness;
      //  Top
			if (normalColor < -0.05) {
				hue = 191;
				lightness = (normalColor + 1) * 50 + 50;
			}
			// Bottom
			else if (normalColor > 0.05) {
				hue = normalColor * 20 + 20;
				lightness = normalColor * 24 + 18;
			}
			// Middle threshold
			else {
				const blend = (normalColor + 0.05) * 10;
				hue = 20;
				lightness = (1 - blend) * 82 + 18;
			}
			
			// lightness *= normalReflection * 0.5 + 0.5;
			const fresnelBlend = Math.abs(normalReflection - 0.5) * 2;
			lightness *= lerp(0.65, 1, fresnelBlend);
			
			// Specular highlight
			if (normalLight < -0.96) {
				const blend = (1 - ((1 + normalLight) * 25)) ** 4;
				lightness = lerp(lightness, 100, blend);
			}
			
			ctx.fillStyle = `hsl(${hue},${p.color.s}%,${lightness}%)`;
			
			fillPoly(p);
		});
	}
	else if (selectedMaterial === MATERIALS.NORMALS) {
		activePolys.forEach(p => {
			if (p.normalCamera.z < 0) return;

			const r = (p.normalModel.x + 1) / 2 * 255 | 0;
			const g = (p.normalModel.y + 1) / 2 * 255 | 0;
			const b = (p.normalModel.z + 1) / 2 * 255 | 0;
			ctx.fillStyle = `rgb(${r},${g},${b})`;
			
			fillPoly(p);
		});
	}
	
	if (drawNormalLines) {
		ctx.strokeStyle = '#f90';
		ctx.lineWidth = 0.5 / scale;
		ctx.beginPath();
		activePolys.forEach(p => {
			if (p.normalCamera.z >= 0) return;
			computePolyMiddle(p);
			ctx.moveTo(p.middle.x, p.middle.y);
			ctx.lineTo(p.middle.x + 0.12*p.normalCamera.x, p.middle.y + 0.12*p.normalCamera.y);
		});
		ctx.stroke();
		ctx.lineWidth = 1 / scale;
		ctx.beginPath();
		activePolys.forEach(p => {
			if (p.normalCamera.z < 0) return;
			computePolyMiddle(p);
			ctx.moveTo(p.middle.x, p.middle.y);
			ctx.lineTo(p.middle.x + 0.12*p.normalCamera.x, p.middle.y + 0.12*p.normalCamera.y);
		});
		ctx.stroke();
	}
	
	ctx.restore();
	
	updateRenderTime(performance.now() - renderStartTime);
}	

// Simple render time display with a moving average.
const renderTimeNode = document.createElement('div');
renderTimeNode.classList.add('render-time');
document.body.appendChild(renderTimeNode);
const tickTimeLog = [];
const renderTimeLog = [];
function updateTickTime(timeMs) {
	tickTimeLog.push(timeMs);
}
function updateRenderTime(timeMs) {
	renderTimeLog.push(timeMs);
	if (renderTimeLog.length > 30) {
		const tickTime = tickTimeLog.reduce((a, b) => a + b) / tickTimeLog.length;
		const renderTime = renderTimeLog.reduce((a, b) => a + b) / renderTimeLog.length;
		renderTimeNode.innerHTML = `Polys: ${getActivePolys().length} (${renderedPolyCount})<br>Tick:  ${tickTime.toFixed(2)}ms<br>Draw:  ${renderTime.toFixed(2)}ms<br>Total: ${(tickTime + renderTime).toFixed(2)}ms`;
		tickTimeLog.length = 0;
		renderTimeLog.length = 0;
	}
}


// Interaction
// -----------------------------

// Allow switching models
const modelSelectNode = document.querySelector('.model-select');
Object.keys(entities).forEach(entityName => {
	const optionNode = document.createElement('option');
	optionNode.setAttribute('value', entityName);
	optionNode.textContent = entityName;
	if (entityName === selectedModel) {
		optionNode.selected = true;
	}
	modelSelectNode.appendChild(optionNode);
});
modelSelectNode.addEventListener('input', evt => {
	selectedModel = modelSelectNode.value;
});

// Allow switching materials
const materialSelectNode = document.querySelector('.material-select');
Object.keys(MATERIALS).forEach(materialName => {
	const optionNode = document.createElement('option');
	optionNode.setAttribute('value', materialName);
	optionNode.textContent = materialName;
	if (materialName === selectedMaterial) {
		optionNode.selected = true;
	}
	materialSelectNode.appendChild(optionNode);
});
materialSelectNode.addEventListener('input', evt => {
	selectedMaterial = materialSelectNode.value;
});

// Allow toggling normal rendering
const normalToggleBtn = document.querySelector('.normal-toggle');
function updateNormalToggleBtn() {
	normalToggleBtn.innerHTML = `Normals: ${drawNormalLines ? 'On' : 'Off'}`;
}
updateNormalToggleBtn();
normalToggleBtn.addEventListener('click', () => {
	drawNormalLines = !drawNormalLines;
	updateNormalToggleBtn();
});

// Interaction state
let pointerIsDown = false;
let pointerStart = { x: 0, y: 0 };
let pointerDelta = { x: 0, y: 0 };

function handlePointerDown(x, y) {
	if (!pointerIsDown) {
		pointerIsDown = true;
		pointerStart.x = x;
		pointerStart.y = y;
	}
}

function handlePointerUp() {
	pointerIsDown = false;
	// Apply rotation
	rotationAutoY += pointerDelta.x;
	rotationAutoZ += pointerDelta.y;
	// Reset delta
	pointerDelta.x = 0;
	pointerDelta.y = 0;
}

function handlePointerMove(x, y) {
	if (pointerIsDown) {
		const maxRotationX = Math.PI * 1.2;
		const maxRotationY = Math.PI * 1.2;
		pointerDelta.x = (x - pointerStart.x) / stage.width * maxRotationX;
		pointerDelta.y = (y - pointerStart.y) / stage.height * maxRotationY;
	}
}


// Use pointer events if available, otherwise fallback to touch events (for iOS).
if ('PointerEvent' in window) {
	stage.canvas.addEventListener('pointerdown', event => {
		event.isPrimary && handlePointerDown(event.clientX, event.clientY);
	});

	stage.canvas.addEventListener('pointerup', event => {
		event.isPrimary && handlePointerUp();
	});

	stage.canvas.addEventListener('pointermove', event => {
		event.isPrimary && handlePointerMove(event.clientX, event.clientY);
	});
} else {
	let activeTouchId = null;
	stage.canvas.addEventListener('touchstart', event => {
		if (!pointerIsDown) {
			const touch = event.changedTouches[0];
			activeTouchId = touch.identifier;
			handlePointerDown(touch.clientX, touch.clientY);
		}
	});
	stage.canvas.addEventListener('touchend', event => {
		for (let touch of event.changedTouches) {
			if (touch.identifier === activeTouchId) {
				handlePointerUp();
				break;
			}
		}
	});
	stage.canvas.addEventListener('touchmove', event => {
		for (let touch of event.changedTouches) {
			if (touch.identifier === activeTouchId) {
				handlePointerMove(touch.clientX, touch.clientY);
				event.preventDefault();
				break;
			}
		}
	}, { passive: false });
}

That’s all! hopefully, you have successfully created a 3D Model Viewer. If you have any questions or suggestions, feel free to comment below.

Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *