Canvas JavaScript

A Raytraced Subsampled Paraboloid

This JavaScript program demonstrates how to draw a raytraced, subsampled paraboloid with lighting using an image data instance to set individual pixels on a canvas element. Use the arrow keys to change the view.

RaytracedParaboloid.html

<!DOCTYPE html>
<html>
	<head>
		<title>XoaX.net's Javascript</title>
		<script type="text/javascript" src="RaytracedParaboloid.js"></script>
		<style>
			.cFocus { border: 1px red solid; }
			.cBlur { border: none; }
		</style>
	</head>
	<body onload="Initialize()">
		<div style="float:left;">
			<canvas id="idCanvas" width="600" height ="600" style="background-color: #FFFFFF;"></canvas><br />
			<label for="idTime">Rendering Time: <input id="idTime" type="text" size="10" /> milliseconds</label>
		</div>
	</body>
</html>

RaytracedParaboloid.js

// I will use a light from above to light the graph. Since this is a function, there are no shadows.
class CGraph {
	constructor(fnF) {
		this.mdaaBounds = [[-1, 1], [-1, 1], [-1, 1]];
	}
	// Get two intersections with a ray. The first one is semi-tranparent. The second is opaque, possibly with a grid.
	// Intersect the graph in between and composite it.
	Intersect(daP, daV, fnF, fnGradF) {
		// Makes sure that the direction is nonzero or nearly zero so that we do not divide by zero. Otherwise, we will skip it
		let baSkip = [false, false, false];
		for (let i = 0; i < 3; ++i) {
			if (Math.abs(daV[i]) < 1.0e-15) {
				baSkip[i] = true;
			}
		}
		let iSideIntersections = [];
		let daT = [];
		for (let i = 0; i < 3; ++i) {
			// Makes sure that the direction is nonzero so that we do not divide by zero.
			if (!baSkip[i]) {
				// Loop over the low and high values for the range for this coordinate
				// Find the t values of the intersections for the low and high x values
				// e.g. For x, we have x0 = Px + t*Vx => t = (x0 - Px)/Vx
				for (let j = 0; j < 2; ++j) {
					let dT = (this.mdaaBounds[i][j] - daP[i])/daV[i];
					// Loop over the other coordinates and make sure that the intersection is within the range
					let bInRange = true
					for (let k = 0; k < 2; ++k) {
						let iCoord = ((i + k + 1) % 3);
						let dP = daP[iCoord]+dT*daV[iCoord];
						if (dP < this.mdaaBounds[iCoord][0] || dP > this.mdaaBounds[iCoord][1]) {
							bInRange = false;
						}
					}
					if (bInRange) {
						if (daT.length == 0) {
							daT.push(dT);
							iSideIntersections.push([i,j]);
						} else if (daT.length == 1) {
							// If the new dT value is greater, put it at the end. Otherwise, put it in the first position with unshift
							if (daT[0] < dT) {
								daT.push(dT);
								iSideIntersections.push([i,j]);
							} else {
								daT.unshift(dT);
								iSideIntersections.unshift([i,j]);
							}
							break;
						}
					}
				}
			}
			if (daT.length == 2) {
				break;
			}
		}
		// There are at most 6 entries. Sort them and remove duplicates
		let daColor = [0,0,0,255];
		let daColorX = [255,200,200,255];
		let daColorY = [200,255,200,255];
		let daColorZ = [200,200,255,255];
		let dAlpha = 0.0;
		let daaColors = [daColorX, daColorY, daColorZ];
		for (let i = 0; i < daT.length; ++i) {
			// There is only two intersections. The alpha of the first intesection is just the second number.
			let dColorAlpha = i*.85 + .15;
			let iDim = iSideIntersections[i][0];
			if (i == 1) {
				// Get the intersection near the front
				let dT1 = daT[0];
				let dT2 = daT[1];
				let daDF1 = [0.0, 0.0];
				while ((dT1 >= daT[0]) && (dT1 <= daT[1]) && (Math.abs(dT1 - dT2) >= 1.0e-10)) {
					let dX1 = daP[0] + dT1*daV[0];
					let dY1 = daP[1] + dT1*daV[1];
					let dZ1 = daP[2] + dT1*daV[2];
					let dF1 = fnF(dX1, dY1);
					daDF1 = fnGradF(dX1, dY1);
					dT2 = dT1;
					// Get the z values at each of these points and determine where the function crosses the the line
					// z = F(x, y), L(t) = [x(t), y(t), z(t)], G(t) = z(t) - F(x(t), y(t))
					// G'(t) = z'(t) - [dF(x(t), y(t))/dx*x'(t) + dF(x(t), y(t))/dy*y'(t)]
					// x'(t), y'(t), and z'(t) are all constants since L(t) = [at + b, ct + d, et + f]
					// G'(t) = e - dF/dx(t)*a - dF/dy(t)*c
					// Newton's method x(n) = x(n-1) - f(x(n-1))/f'(x(n-1)), in this case, we use t.
					// t(n) = t(n-1) - G(t(n-1))/G'(t(n-1))
					dT1 = dT1 - (dZ1 - dF1)/(daV[2] - daDF1[0]*daV[0] - daDF1[1]*daV[1]);
				}
				let dFirstT = dT1;
				if (Math.abs(dT1 - dT2) < 1.0e-10) {
  				// Should this be the top or bottom of the surface? Check the gradient
  				let dDot = daV[2] - daDF1[0]*daV[0] - daDF1[1]*daV[1];
  				// If we are seeing the top of the surface;
  				if (dDot < 0.0) {
  					let dMag = Math.sqrt(daDF1[0]*daDF1[0] + daDF1[1]*daDF1[1] + 1);
  					let daGradient = [-daDF1[0]/dMag, -daDF1[1]/dMag, 1.0/dMag];
  					let daSurfaceColor = [255,255,255,255];
  					// Change the color to draw lines on the surface
						let dMx = 10.0*(daP[0] + dT1*daV[0]);
						let dMy = 10.0*(daP[1] + dT1*daV[1]);
						if ((Math.abs(dMx - Math.round(dMx)) < .02) || (Math.abs(dMy - Math.round(dMy)) < .02)) {
							daSurfaceColor = [200,200,200,255];
						}
  					daColor[0] += (1.0 - dAlpha)*.95*(daGradient[2])*daSurfaceColor[0];
  					daColor[1] += (1.0 - dAlpha)*.95*(daGradient[2])*daSurfaceColor[1];
  					daColor[2] += (1.0 - dAlpha)*.95*(daGradient[2])*daSurfaceColor[2];
  					dAlpha += (1.0 - dAlpha)*.95;
  				} else {
  					let daBlack = [0,0,0,255];
  					daColor[0] += (1.0 - dAlpha)*daBlack[0];
  					daColor[1] += (1.0 - dAlpha)*daBlack[1];
  					daColor[2] += (1.0 - dAlpha)*daBlack[2];
  					dAlpha += (1.0 - dAlpha);
  				}
				}
				// Find a potential back intersection from the back side
				// This could only be a back side intersection because we have the first intersection already.
				// Also, the surface is a convex set. So, it has has at most 2 intersections.
				dT1 = daT[1];
				dT2 = daT[0];
				daDF1 = [0.0, 0.0];
				while ((dT1 >= daT[0]) && (dT1 <= daT[1]) && (Math.abs(dT1 - dT2) >= 1.0e-10)) {
					let dX1 = daP[0] + dT1*daV[0];
					let dY1 = daP[1] + dT1*daV[1];
					let dZ1 = daP[2] + dT1*daV[2];
					let dF1 = fnF(dX1, dY1);
					daDF1 = fnGradF(dX1, dY1);
					dT2 = dT1;
					dT1 = dT1 - (dZ1 - dF1)/(daV[2] - daDF1[0]*daV[0] - daDF1[1]*daV[1]);
				}
				// We want to make sure that the t values are different for the second intersection, before using it.
				if ((Math.abs(dT1 - dT2) < 1.0e-10) && (Math.abs(dT1 - dFirstT) > 1.0e-5)) {
					let daBlack = [0,0,0,255];
					daColor[0] += (1.0 - dAlpha)*daBlack[0];
					daColor[1] += (1.0 - dAlpha)*daBlack[1];
					daColor[2] += (1.0 - dAlpha)*daBlack[2];
					dAlpha += (1.0 - dAlpha);
				}
			}
			daColor[0] += (1.0 - dAlpha)*dColorAlpha*daaColors[iDim][0];
			daColor[1] += (1.0 - dAlpha)*dColorAlpha*daaColors[iDim][1];
			daColor[2] += (1.0 - dAlpha)*dColorAlpha*daaColors[iDim][2];
			dAlpha += (1.0 - dAlpha)*dColorAlpha;
		}
		if (daT.length > 0) {
			return daColor;
		}
		return null;
	}
}

// z = 1 - x^2 - y^2
function Paraboloid(dX, dY) {
	return 1-dX*dX-dY*dY;
}

function GradParaboloid(dX, dY) {
	return [-2*dX, -2*dY];
}

class CCanvasPlane {
	// Pass in the canvas size in pixels and the size in space
	// The pixels of the canvas will be cenered at the origin
	// So, if the canvas is 600x600 pixels. Pixel (299.5, 299.5) will be at the origin,
	// with pixels starting at 0 and going to 599
	constructor(sCanvasId, dW, dH)  {
		var qCanvas = document.getElementById(sCanvasId);
		this.mqContext = qCanvas.getContext("2d");
		this.miPixelW = qCanvas.width;
		this.miPixelH = qCanvas.height;
		this.mqImData = this.mqContext.createImageData(this.miPixelW, this.miPixelH);
		this.mdW = dW;
		this.mdH = dH;
		// Set some default angles
		this.mqAlpha = Math.PI/6;
		this.mqBeta = -Math.PI/6;
	}
	
	Clear() {
		this.mqContext.clearRect(0, 0, 600, 600);
	}
	
	Left() {
		this.mqAlpha +=  Math.PI/48;
	}
	Right() {
		this.mqAlpha -=  Math.PI/48;
	}
	Up() {
		if (this.mqBeta + Math.PI/48 < Math.PI/2 - .01) {
			this.mqBeta +=  Math.PI/48;
		}
	}
	Down() {
		if (this.mqBeta - Math.PI/48 > -Math.PI/2 + .01) {
			this.mqBeta -=  Math.PI/48;
		}
	}
	
	CreateCoordinateVectors() {
		var daaA = [];
			// This is the x direction of the canvas inside the plane z= 0 in space coordinates
			daaA[0] = [Math.cos(this.mqAlpha), Math.sin(this.mqAlpha), 0];
			// cos(beta)*up + sin(beta)*(vector perp to x pointing forward), since beta is angle between the canvas and the z-axis
			daaA[1] = [-daaA[0][1]*Math.sin(this.mqBeta), daaA[0][0]*Math.sin(this.mqBeta), Math.cos(this.mqBeta)];
			// The vector straigth out of canvas. The cross product of the previous vectors
			daaA[2] = [daaA[0][1]*daaA[1][2] - daaA[0][2]*daaA[1][1],
				daaA[0][2]*daaA[1][0] - daaA[0][0]*daaA[1][2],
				daaA[0][0]*daaA[1][1] - daaA[0][1]*daaA[1][0]];
		return daaA;
	}
	
	DrawScene() {
		gdStartTime = performance.now();
		let qGraph = new CGraph(null);
		// The pixel width and height in the coordinates of the space
		var dPixWidth = this.mdW/this.miPixelW;
		var dPixHeight = this.mdH/this.miPixelH;
		var qCanvasInSpace = this.CreateCoordinateVectors();
		var daDirX = qCanvasInSpace[0];
		var daDirY = [-qCanvasInSpace[1][0], -qCanvasInSpace[1][1], -qCanvasInSpace[1][2]];
		var daView = qCanvasInSpace[2];
		var dPixStartX = dPixWidth*.5 - (this.mdW/2);
		var dPixStartY = dPixHeight*.5 - (this.mdH/2);
		// The position in space of the center of the first pixel
		var daPixPosInit = [dPixStartX*daDirX[0]+dPixStartY*daDirY[0], 
			dPixStartX*daDirX[1]+dPixStartY*daDirY[1], 
			dPixStartX*daDirX[2]+dPixStartY*daDirY[2]];
		// The position of the center of the first pixel in the current row.
		var daPixPosRow = [daPixPosInit[0], daPixPosInit[1], daPixPosInit[2]];
		// The center point of the current pixel.
		var daPixPos = [daPixPosInit[0], daPixPosInit[1], daPixPosInit[2]];
		// The translation vector for a pixel in the x-direction
		var daPixDx = [dPixWidth*daDirX[0], dPixWidth*daDirX[1], dPixWidth*daDirX[2]];
		// The translation vector for a pixel in the y-direction
		var daPixDy = [dPixHeight*daDirY[0], dPixHeight*daDirY[1], dPixHeight*daDirY[2]];
		// The current pixel index
		var iPix = 0;
		var daTanDir = [0.0, 0.0, 0.0];
		//var daLightDir = [-1.0/Math.sqrt(14.0), -2.0/Math.sqrt(14.0), -3.0/Math.sqrt(14.0)];
		var daLightDir = [0, 0, -1];
		for (var i = 0; i < this.miPixelW; ++i) {
			for (var j = 0; j < this.miPixelH; ++j) {
				var daSubDx = [daPixDx[0]/4.0, daPixDx[1]/4.0, daPixDx[2]/4.0];
				var daSubDy = [daPixDy[0]/4.0, daPixDy[1]/4.0, daPixDy[2]/4.0];
				var daSubPosRow = [daPixPos[0] - 1.5*(daSubDx[0] + daSubDy[0]),
					daPixPos[1] - 1.5*(daSubDx[1] + daSubDy[1]),
					daPixPos[2] - 1.5*(daSubDx[2] + daSubDy[2])];
				var daSubPos = [daSubPosRow[0], daSubPosRow[1], daSubPosRow[2]];
				var dR = 0.0;
				var dG = 0.0;
				var dB = 0.0;
				var dA = 0.0;
				// Pixel subsampling 4x4 = 16 samples per pixel.
				for (var m = 0; m < 4; ++m) {
					for (var n = 0; n < 4; ++n) {
						let daColor = qGraph.Intersect(daSubPos, daView, Paraboloid, GradParaboloid);
						if (daColor == null) {
								dR += 240;
								dG += 240;
								dB += 240;
								dA += 0;
						} else {
								dR += daColor[0];
								dG += daColor[1];
								dB += daColor[2];
								dA += 16;
						}
						daSubPos[0] += daSubDx[0];
						daSubPos[1] += daSubDx[1];
						daSubPos[2] += daSubDx[2];
					}
					daSubPosRow[0] += daSubDy[0];
					daSubPosRow[1] += daSubDy[1];
					daSubPosRow[2] += daSubDy[2];
					daSubPos[0] = daSubPosRow[0];
					daSubPos[1] = daSubPosRow[1];
					daSubPos[2] = daSubPosRow[2];
				}
				// The sum is over 16 samples. So, divide by the sample size.
				this.mqImData.data[iPix] = dR/16.0;
				this.mqImData.data[iPix+1] = dG/16.0;
				this.mqImData.data[iPix+2] = dB/16.0;
				this.mqImData.data[iPix+3] = (dA > 255) ? 255 : dA;
				daPixPos[0] += daPixDx[0];
				daPixPos[1] += daPixDx[1];
				daPixPos[2] += daPixDx[2];
				iPix += 4;
			}
			daPixPosRow[0] += daPixDy[0];
			daPixPosRow[1] += daPixDy[1];
			daPixPosRow[2] += daPixDy[2];
			daPixPos[0] = daPixPosRow[0];
			daPixPos[1] = daPixPosRow[1];
			daPixPos[2] = daPixPosRow[2];
		}
		// Use the image data to draw the pixels at (0, 0)
		this.mqContext.putImageData(this.mqImData, 0, 0);
		gqTimeInput.value = Math.round(performance.now() - gdStartTime);
	}
}

var qCP = null;
var gqTimeInput = null;
var gdStartTime = 0.0;

function Initialize() {
	gqTimeInput = document.getElementById("idTime");
	qCP = new CCanvasPlane("idCanvas", 4.0, 4.0);
	window.onkeydown=KeyDownFunction;
	window.addEventListener("focus", FocusFunction);
	window.addEventListener("blur", BlurFunction);
	//window.focus();
	FocusFunction();
	qCP.DrawScene();
}

function FocusFunction() {
	document.getElementById("idCanvas").className ='cFocus';
}

function BlurFunction() {
	document.getElementById("idCanvas").className ='cBlur';
}

function KeyDownFunction(e) {
	var iKeyUp = 38;
	var iKeyLeft = 37;
	var iKeyRight = 39;
	var iKeyDown = 40;
	var iKeyCode = 0;
	if (e) {
		iKeyCode = e.which;
	} else {
		iKeyCode = window.event.keyCode;
	}
	qCP.Clear();
	switch (iKeyCode) {
		case iKeyUp: {
			qCP.Up();
			// Prevent the window scrolling
      e.preventDefault();
			break;
		}
		case iKeyLeft: {
			qCP.Left();
			// Prevent the window scrolling
      e.preventDefault();
			break;
		}
		case iKeyRight: {
			qCP.Right();
			// Prevent the window scrolling
      e.preventDefault();
			break;
		}
		case iKeyDown: {
			qCP.Down();
			// Prevent the window scrolling
      e.preventDefault();
			break;
		}
		default: {
			break;
		}
	}
	qCP.DrawScene();
}
 

Output

 
 

© 2007–2026 XoaX.net LLC. All rights reserved.