<wc-geo-rt></wc-geo-rt>
const TWO_PI = Math.PI * 2
const QUARTER_TURN = Math.PI / 2;

function normalizeAngle(angle) {
	if (angle < 0) {
		return TWO_PI - (Math.abs(angle) % TWO_PI);
	}
	return angle % TWO_PI;
}

function radToDegrees(rad) {
	return rad * 180 / Math.PI;
}

function cartesianToLatLng([x, y, z]) {
	const radius = Math.sqrt(x ** 2 + y ** 2 + z ** 2);
	return [
		radius,
		(Math.PI / 2) - Math.acos(y / radius),
		normalizeAngle(Math.atan2(x, -z)),
	];
}

function latLngToCartesian([radius, lat, lng]) {
	lng = -lng + Math.PI / 2;
	return [
		radius * Math.cos(lat) * Math.cos(lng),
		radius * Math.sin(lat),
		radius * -Math.cos(lat) * Math.sin(lng),
	];
}

function clamp(value, low, high) {
	low = low !== undefined ? low : Number.MIN_SAFE_INTEGER;
	high = high !== undefined ? high : Number.MAX_SAFE_INTEGER;
	if (value < low) {
		value = low;
	}
	if (value > high) {
		value = high;
	}
	return value;
}

function lerp(start, end, normalValue) {
	return start + (end - start) * normalValue;
}

function inverseLerp(start, end, value) {
	return (value - start) / (end - start);
}

function normalizeNumber(num, len) {
	num = parseFloat(num.toFixed(len));
	num = num === -0 ? 0 : num;
	return num;
}


function transpose(matrix) {
	return [
		[matrix[0][0], matrix[1][0], matrix[2][0], matrix[3][0]],
		[matrix[0][1], matrix[1][1], matrix[2][1], matrix[3][1]],
		[matrix[0][2], matrix[1][2], matrix[2][2], matrix[3][2]],
		[matrix[0][3], matrix[1][3], matrix[2][3], matrix[3][3]]
	];
}

function getRotationXMatrix(theta) {
	return [
		[1, 0, 0, 0],
		[0, Math.cos(theta), -Math.sin(theta), 0],
		[0, Math.sin(theta), Math.cos(theta), 0],
		[0, 0, 0, 1]
	];
}

function getRotationYMatrix(theta) {
	return [
		[Math.cos(theta), 0, Math.sin(theta), 0],
		[0, 1, 0, 0],
		[-Math.sin(theta), 0, Math.cos(theta), 0],
		[0, 0, 0, 1]
	];
}

function getRotationZMatrix(theta) {
	return [
		[Math.cos(theta), -Math.sin(theta), 0, 0],
		[Math.sin(theta), Math.cos(theta), 0, 0],
		[0, 0, 1, 0],
		[0, 0, 0, 1]
	];
}

function getTranslationMatrix(x, y, z) {
	return [
		[1, 0, 0, x],
		[0, 1, 0, y],
		[0, 0, 1, z],
		[0, 0, 0, 1]
	];
}

function getScaleMatrix(x, y, z){
	return [
		[x, 0, 0, 0],
		[0, y, 0, 0],
		[0, 0, z, 0],
		[0, 0, 0, 1]
	];
}

function multiplyMatrix(a, b) {
	const matrix = [
		new Array(4),
		new Array(4),
		new Array(4),
		new Array(4)
	];
	for (let c = 0; c < 4; c++) {
		for (let r = 0; r < 4; r++) {
			matrix[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c] + a[r][3] * b[3][c];
		}
	}

	return matrix;
}

function getIdentityMatrix() {
	return [
		[1, 0, 0, 0],
		[0, 1, 0, 0],
		[0, 0, 1, 0],
		[0, 0, 0, 1]
	];
}

function multiplyMatrixVector(vector, matrix) {
	//normalize 3 vectors
	if (vector.length === 3) {
		vector.push(1);
	}

	return [
		vector[0] * matrix[0][0] + vector[1] * matrix[0][1] + vector[2] * matrix[0][2] + vector[3] * matrix[0][3],
		vector[0] * matrix[1][0] + vector[1] * matrix[1][1] + vector[2] * matrix[1][2] + vector[3] * matrix[1][3],
		vector[0] * matrix[2][0] + vector[1] * matrix[2][1] + vector[2] * matrix[2][2] + vector[3] * matrix[2][3],
		vector[0] * matrix[3][0] + vector[1] * matrix[3][1] + vector[2] * matrix[3][2] + vector[3] * matrix[3][3]
	];
}

function getVectorMagnitude(vec) {
	let sum = 0;
	for(const el of vec){
		sum += el ** 2;
	}
	return Math.sqrt(sum);
}

function addVector(a, b) {
	return [
		a[0] + b[0],
		a[1] + b[1],
		a[2] + b[2]
	];
}

function subtractVector(a, b) {
	return [
		a[0] - b[0],
		a[1] - b[1],
		a[2] - b[2]
	];
}

function multiplyVector(vec, s) {
	return [
		vec[0] * s,
		vec[1] * s,
		vec[2] * s
	];
}

function divideVector(vec, s) {
	return [
		vec[0] / s,
		vec[1] / s,
		vec[2] / s
	];
}

function normalizeVector(vec) {
	return divideVector(vec, getVectorMagnitude(vec));
}

function crossVector(a, b) {
	return [
		a[1] * b[2] - a[2] * b[1],
		a[2] * b[0] - a[0] * b[2],
		a[0] * b[1] - a[1] * b[0]
	];
}

function dotVector(a, b) {
	return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}

function invertVector(vec){
	return vec.map(x => -x);
}

const UP = [0, 1, 0];
const FORWARD = [0, 0, 1];
const RIGHT = [1, 0, 0];

///
function getVectorIntersectPlane(planePoint, planeNormal, lineStart, lineEnd) {
	planeNormal = normalizeVector(planeNormal);
	const planeDot = dotVector(planePoint, planeNormal);
	const startDot = dotVector(lineStart, planeNormal);
	const endDot = dotVector(lineEnd, planeNormal);
	const t = (planeDot - startDot) / (endDot - startDot);
	if (t === Infinity || t === -Infinity) {
		return null;
	}
	const line = subtractVector(lineEnd, lineStart);
	const deltaToIntersect = multiplyVector(line, t);
	return addVector(lineStart, deltaToIntersect);
}

function isPointInInsideSpace(point, planeNormal, planePoint) {
	planeNormal = normalizeVector(planeNormal);
	return dotVector(planeNormal, subtractVector(planePoint, point)) > 0;
}

function reflectVector(vec, normal) {
	return [
		vec[0] - 2 * dotVector(vec, normal) * normal[0],
		vec[1] - 2 * dotVector(vec, normal) * normal[1],
		vec[2] - 2 * dotVector(vec, normal) * normal[2],
	];
}

class Camera {
	#position = [0, 0, -1];
	#target = [0, 0, 0];
	#screenWidth;
	#screenHeight;
	#near = 0.01;
	#far = 5;

	constructor(camera) {
		this.#position = camera.position;
		this.#screenWidth = camera.screenWidth;
		this.#screenHeight = camera.screenHeight;
		this.#near = camera.near ?? this.#near;
		this.#far = camera.far ?? this.#far;
	}

	moveTo(x, y, z) {
		this.#position = [x, y, z];
	}

	moveBy({ x = 0, y = 0, z = 0 }) {
		this.#position[0] += x;
		this.#position[1] += y;
		this.#position[2] += z;
	}

	panBy({ x = 0, y = 0, z = 0 }) {
		this.#position[0] += x;
		this.#target[0] += x;
		this.#position[1] += y;
		this.#target[1] += y;
		this.#position[2] += z;
		this.#target[2] += z;
	}

	orbitBy({ lat = 0, long = 0, radius = 0 }) {
		const [r, currentLat, currentLng] = this.getOrbit();
		const newLat = clamp(currentLat + lat, -Math.PI / 2, Math.PI / 2);
		const newRadius = Math.max(0.1, r + radius);
		this.#position = latLngToCartesian([newRadius, newLat, currentLng - long]);
	}

	zoomBy(value) {
		const [r, currentLat, currentLng] = this.getOrbit();
		const newRadius = Math.max(0.1, r / value);
		this.#position = latLngToCartesian([newRadius, currentLat, currentLng]);
	}

	lookAt(x, y, z) {
		this.#target = [x, y, z];
	}

	getForwardDirection(){
		return normalizeVector(subtractVector(this.#target, this.#position));
	}

	getRightDirection(){
		return crossVector(UP, this.getForwardDirection());
	}

	getUpDirection(){
		return crossVector(this.getForwardDirection(), this.getRightDirection());
	}

	getAspectRatio(){
		return this.#screenWidth / this.#screenHeight;
	}

	getOrbit() {
		const targetDelta = subtractVector(this.#position, this.#target);
		return cartesianToLatLng(targetDelta);
	}

	getPosition() {
		return this.#position;
	}

	setPosition(position) {
		this.#position = position;
	}
}

class WcGeoRt extends HTMLElement {
	#context;
	#width = 1280;
	#height = 720;

	constructor(){
		super();
		this.bind(this);
	}
	bind(element){
		element.attachEvents = element.attachEvents.bind(element);
		element.cacheDom = element.cacheDom.bind(element);
		element.createShadowDom = element.createShadowDom.bind(element);
		element.createCameras = element.createCameras.bind(element);
		element.createMeshes = element.createMeshes.bind(element);
		element.render = element.render.bind(element);
		element.raytrace = element.raytrace.bind(element);
	}
	async connectedCallback() {
		this.createShadowDom();
		this.cacheDom();
		this.attachEvents();


		this.createCameras();
		this.createMeshes();

		this.#context = this.dom.canvas.getContext("2d");

		this.render();
	}
	createShadowDom() {
		this.shadow = this.attachShadow({ mode: "open" });
		this.shadow.innerHTML = `
				<style>
					:host { display: block; }
				</style>
				<canvas width="${this.#width}" height="${this.#height}" style="border: 1px solid black"></canvas>
			`;
	}
	createCameras(){
		this.cameras = {
			default: new Camera({
				position: [0, 0, -2],
				screenHeight: this.#height,
				screenWidth: this.#width,
				near: 0,
				far: 5
			})
		}
	}
	createMeshes(){
		this.meshes = {
			sphere: {
				position: [0,0,0],
				radius: 1
			}
		}
	}
	cacheDom() {
		this.dom = {
			canvas: this.shadow.querySelector("canvas")
		};
	}
	render(){
		const pixelData = this.#context.getImageData(0, 0, this.#width, this.#height);
		const halfVolumeHeight = 1;
		const halfPixelHeight = this.#height / 2;
		const pixelHeightRatio = halfVolumeHeight / halfPixelHeight;
		const halfVolumeWidth = this.cameras.default.getAspectRatio();
		const halfPixelWidth = this.#width / 2;
		const pixelWidthRatio = halfVolumeWidth / halfPixelWidth;

		for (let row = 0; row < this.#height; row++) {
			for (let col = 0; col < this.#width; col++) {
				const xDelta = multiplyVector(this.cameras.default.getRightDirection(), (col - halfPixelWidth) * pixelWidthRatio);
				const yDelta = multiplyVector(multiplyVector(this.cameras.default.getUpDirection(), -1), (row - halfPixelHeight) * pixelHeightRatio);

				const ray = {
					origin: this.cameras.default.getPosition(),
					direction: normalizeVector(addVector(addVector(this.cameras.default.getForwardDirection(), xDelta), yDelta))
				};

				const color = this.raytrace(ray);

				const index = (row * this.#width * 4) + (col * 4);
				pixelData.data[index + 0] = color[0];
				pixelData.data[index + 1] = color[1];
				pixelData.data[index + 2] = color[2];
				pixelData.data[index + 3] = 255;
			}
		}

		this.#context.putImageData(pixelData, 0, 0);
	}
	raytrace(ray){
		const intersection = this.intersectObjects(ray);

		if (intersection.distance === Infinity) {
			return [255, 255, 255];
		}

		return [255, 0, 0];
	}
	intersectObjects(ray) {
		let closest = { distance: Infinity, mesh: null };
		for (let mesh of Object.values(this.meshes)) {
			const distance = this.sphereIntersection(ray, mesh);
			if (distance != undefined && distance < closest.distance) {
				closest = { distance, mesh };
			}
		}
		return closest;
	}
	sphereIntersection(ray, sphere) {
		const a = dotVector(ray.direction, ray.direction);
		const cameraToCenter = subtractVector(ray.origin, sphere.position);
		const b = 2 * dotVector(ray.direction, cameraToCenter);
		const c = dotVector(cameraToCenter, cameraToCenter) - sphere.radius ** 2;
		const discriminant = (b ** 2) - (4 * a * c);

		if(discriminant < 0) return undefined; //no solution, no hit

		const s1 = (-b + Math.sqrt(discriminant)) / 2*a;
		const s2 = (-b - Math.sqrt(discriminant)) / 2*a;

		if(s1 < 0 || s2 < 0) return undefined; //either facing away or origin is inside sphere, no hit

		return Math.min(s1, s2);
	}
	attachEvents() {
	}

	//Attrs
	attributeChangedCallback(name, oldValue, newValue) {
		if (newValue !== oldValue) {
			this[name] = newValue;
		}
	}
	set height(value) {
		this.#height = value;
		if (this.dom) {
			this.dom.canvas.height = value;
		}
	}
	set width(value) {
		this.#width = value;
		if (this.dom) {
			this.dom.canvas.height = value;
		}
	}
}

customElements.define("wc-geo-rt", WcGeoRt);

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.