class Vec2 { x: number; y: number; constructor(x: number, y: number) { this.x = x; this.y = y; } add(v: Vec2): Vec2 { return new Vec2(this.x + v.x, this.y + v.y); } sub(v: Vec2): Vec2 { return new Vec2(this.x - v.x, this.y - v.y); } mul(multiplicand: number): Vec2 { return new Vec2(this.x * multiplicand, this.y * multiplicand); } div(divisor: number): Vec2 { return new Vec2(this.x / divisor, this.y / divisor); } dot(v: Vec2): number { return this.x * v.x + this.y * v.y; } length(): number { return Math.sqrt(this.dot(this)); } normalize(): Vec2 { return this.div(this.length()); } cos_angle_to(other: Vec2): number { return this.dot(other) / (this.length() * other.length()); } angle_to(other: Vec2): number { return Math.acos(this.cos_angle_to(other)); } } class Vec3 { // x, right, y forward, z up x: number; y: number; z: number; constructor(x: number, y: number, z: number) { this.x = x; this.y = y; this.z = z; } add(v: Vec3): Vec3 { return new Vec3(this.x + v.x, this.y + v.y, this.z + v.z); } sub(v: Vec3): Vec3 { return new Vec3(this.x - v.x, this.y - v.y, this.z - v.z); } mul(multiplicand: number): Vec3 { return new Vec3(this.x * multiplicand, this.y * multiplicand, this.z * multiplicand); } div(divisor: number): Vec3 { return new Vec3(this.x / divisor, this.y / divisor, this.z / divisor); } dot(v: Vec3): number { return this.x * v.x + this.y * v.y + this.z * v.z; } length(): number { return Math.sqrt(this.dot(this)); } normalize(): Vec2 { return this.div(this.length()); } cos_angle_to(other: Vec3): number { return this.dot(other) / (this.length() * other.length()); } angle_to(other: Vec3): number { return Math.acos(this.cos_angle_to(other)); } // maps angle 0 to angle phi rotate_about_z(phi: number): Vec3 { let sp = Math.sin(phi); let cp = Math.cos(phi); return new Vec3( cp * this.x - sp * this.y, sp * this.x + cp * this.y, this.z ); } // maps angle 0 to angle theta rotate_about_x(theta: number): Vec3 { let st = Math.sin(theta); let ct = Math.cos(theta); return new Vec3( this.x, ct * this.y - st * this.z, st * this.y + ct * this.z, ); } } interface ProjectedPoint { screen_space: Vec2, // [0, width[x[0, height[ camera_space: Vec2, // [-1, 1]x[-1, 1] distance: number, // world units screen_margin: Vec2, // pixels, negative = outside of screen behind: boolean, xhat_screen: Vec2, yhat_screen: Vec2, zhat_screen: Vec2, } class CameraProjection { origin: Vec3; yaw: number; pitch: number; fov: number; width: number; height: number; constructor(origin: Vec3, yaw: number, pitch: number, fov: number, width: number, height: number) { this.origin = origin; this.yaw = yaw; this.pitch = pitch; this.fov = fov; this.width = width; this.height = height; } world_to_camera(point: Vec3, respect_origin: boolean): Vec3 { if (respect_origin) { point = point.sub(this.origin); } return point.rotate_about_z(-this.yaw).rotate_about_x(-this.pitch); } camera_to_world(point: Vec3, respect_origin: boolean): Vec3 { point = point.rotate_about_x(this.pitch).rotate_about_z(this.yaw); if (respect_origin) { point = point.add(this.origin); } return point; } project(point: Vec3, respect_origin: boolean): ProjectedPoint { let camera_space_3 = this.world_to_camera(point, respect_origin); let scale = 1 / Math.tan(this.fov / 2); let camera_space = new Vec2(scale * camera_space_3.x / camera_space_3.y, scale * camera_space_3.z / camera_space_3.y); let screen_space = new Vec2(this.width / 2 + camera_space.x / 2 * this.width, this.height / 2 - camera_space.y / 2 * this.width); let self = this; function vec_screen(origin: Vec3, v: Vec3) { // TODO: PLEASE DO THIS ANALYTICALLY FOR THE LOVE OF GOD let h = 0.00001; let camera_space_3 = self.world_to_camera(origin, true); let camera_space = new Vec2(scale * camera_space_3.x / camera_space_3.y, scale * camera_space_3.z / camera_space_3.y); let screen_space = new Vec2((0.5 * camera_space.x) * self.width, (-0.5 * camera_space.y) * self.width); let v_camera_space_3 = self.world_to_camera(origin.add(v.mul(h)), true); let v_camera_space = new Vec2(scale * v_camera_space_3.x / v_camera_space_3.y, scale * v_camera_space_3.z / v_camera_space_3.y); let v_screen_space = new Vec2((0.5 * v_camera_space.x) * self.width, (-0.5 * v_camera_space.y) * self.width); let diff = (v_screen_space.sub(screen_space)).div(h); return diff; } let distance = camera_space_3.y; let behind = camera_space_3.y <= 0; let margin_x = Math.min(screen_space.x, this.width - screen_space.x); let margin_y = Math.min(screen_space.y, this.height - screen_space.y); // We want the let origin_camera_space = camera_space_3; if (camera_space.x < -1) { // We want to move origin's x such that camera_space.x == -1 // scale * camera_space_3.x / camera_space_3.y == -1 // camera_space_3.x = -camera_space_3.y / scale origin_camera_space.x = -camera_space_3.y / scale; } // All of these are similar if (camera_space.x > 1) { origin_camera_space.x = camera_space_3.y / scale; } if (camera_space.y < -1) { origin_camera_space.z = -camera_space_3.y / scale; } if (camera_space.y > 1) { origin_camera_space.z = camera_space_3.y / scale; } let origin = this.camera_to_world(origin_camera_space, true); return { screen_space: screen_space, camera_space: camera_space, distance: distance, screen_margin: new Vec2(margin_x, margin_y), behind: behind, xhat_screen: vec_screen(origin, new Vec3(1, 0, 0)), yhat_screen: vec_screen(origin, new Vec3(0, 1, 0)), zhat_screen: vec_screen(origin, new Vec3(0, 0, 1)), }; } view_vector(): Vec3 { let yhat = new Vec3(0, 1, 0); return yhat.rotate_about_x(this.pitch).rotate_about_z(this.yaw); } } type RGB = {r: number, g: number, b: number}; interface Drawable { at: Vec3; render(at: ProjectedPoint, camera: CameraProjection, ctx: CanvasRenderingContext2D): boolean; } class Ball { at: Vec3; color: RGB; size: number; constructor(at: Vec3, color: RGB, size: number) { this.at = at; this.color = color; this.size = size; } render(at: ProjectedPoint, cam: CameraProjection, ctx: CanvasRenderingContext2D): boolean { let x_amount = Math.sqrt(Math.pow(at.xhat_screen.x, 2) + Math.pow(at.yhat_screen.x, 2) + Math.pow(at.zhat_screen.x, 2)); let y_amount = Math.sqrt(Math.pow(at.xhat_screen.y, 2) + Math.pow(at.yhat_screen.y, 2) + Math.pow(at.zhat_screen.y, 2)); for (let a = 0; a < 2; a++) { ctx.fillStyle = `rgb(${this.color.r * a}, ${this.color.g * a}, ${this.color.b * a})`; ctx.beginPath(); ctx.ellipse( at.screen_space.x, at.screen_space.y, this.size * x_amount + 1 - a, this.size * y_amount + 1 - a, 0, 0, Math.PI * 2 ); ctx.fill(); } return true; } } class Shadow { at: Vec3; size: number; constructor(at: Vec2, size: number) { this.at = new Vec3(at.x, at.y, 0); this.size = size; } up(): Vec3 { return new Vec3(0, 0, 1); } render(at: ProjectedPoint, cam: CameraProjection, ctx: CanvasRenderingContext2D): boolean { // in world space, we have x_w^2 + y_w^2 = r^2 // in screen space, [ x_w y_w z_w ] is transformed to [ x_s y_s ] // by the basis vectors x->at.xhat_screen, y->at.yhat_screen, z->at.zhat_screen // x_s = x_s_hat * x_w + y_s_hat * y_w + z_s_hat * z_w // TODO: Actually use this to render the ellipse ctx.fillStyle = "black"; ctx.beginPath(); let x_amount = Math.sqrt(Math.pow(at.xhat_screen.x, 2) + Math.pow(at.yhat_screen.x, 2)); let y_amount = Math.sqrt(Math.pow(at.xhat_screen.y, 2) + Math.pow(at.yhat_screen.y, 2)); ctx.ellipse( at.screen_space.x, at.screen_space.y, this.size * x_amount, this.size * y_amount, 0, // rotation 0, Math.PI * 2, ); ctx.fill(); return true; } } const FPS = 60; let should_draw_gizmo = false; let canvas = document.getElementById("frame") as HTMLCanvasElement; let width = window.innerWidth; let height = window.innerHeight; //width = Math.min(width, height); //height = width; canvas.width = width; canvas.height = height; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; let ctx: CanvasRenderingContext2D = canvas.getContext("2d") as CanvasRenderingContext2D; let at = new Vec3(0, -10, 5); let camera = new CameraProjection(at, 0, Math.atan(at.z/at.y), 60 * Math.PI / 180 * 1, width, height); let pressing = new Set(); document.addEventListener("keydown", e => { pressing.add(e.code); if (e.code === "Space") { should_draw_gizmo = !should_draw_gizmo; } }); document.addEventListener("keyup", e => { pressing.delete(e.code); }); function step() { let delta = 1 / FPS; if (pressing.has("ShiftLeft")) { delta *= 0.2; } if (pressing.has("AltLeft")) { delta *= 5; } let position_delta = new Vec3(0, 0, 0); if (pressing.has("KeyW")) { position_delta.y += delta; } if (pressing.has("KeyA")) { position_delta.x -= delta; } if (pressing.has("KeyS")) { position_delta.y -= delta; } if (pressing.has("KeyD")) { position_delta.x += delta; } if (pressing.has("KeyQ")) { position_delta.z += delta; } if (pressing.has("KeyZ")) { position_delta.z -= delta; } camera.origin = camera.origin.add(position_delta.rotate_about_z(camera.yaw)); if (pressing.has("ArrowRight")) { camera.yaw -= delta; } if (pressing.has("ArrowLeft")) { camera.yaw += delta; } if (pressing.has("ArrowUp")) { camera.pitch += delta; } if (pressing.has("ArrowDown")) { camera.pitch -= delta; } redraw(camera); if (should_draw_gizmo) { draw_gizmo(ctx, camera); } } function get_objects(t): Array { let objects: Array = []; for (let x = 5; x >= -5; x--) { for (let y = 5; y >= -5; y--) { objects.push(new Ball(new Vec3(x, y, 1), {r: 100+x*10, g: 100+y*10, b: 100}, 0.3)); objects.push(new Shadow(new Vec2(x, y), 0.3)); } } return objects; } let start = new Date(); function redraw(camera: CameraProjection) { ctx.clearRect(0, 0, width, height); let objects = get_objects(new Date().getTime() - start.getTime()); let rendered = objects .map(o => {return {object: o, at: camera.project(o.at, true )}}) .filter(r => !r.at.behind) .sort((a, b) => -(a.at.distance - b.at.distance)); for (let x of rendered) { x.object.render(x.at, camera, ctx); } } function draw_gizmo(ctx: CanvasRenderingContext2D, cam: CameraProjection) { let size = 0.6; let distance_away = 3; let v = new Vec3(0.0, distance_away, 0.0).rotate_about_x(camera.pitch).rotate_about_z(camera.yaw); let gizmo_world = cam.origin.add(v); // temporarily set the FOV to a known value so the gizmo doesn't depend on the camera FOV let old_fov = cam.fov; //camera.fov = Math.PI / 4; let gizmo_screen = cam.project(gizmo_world, true); let directions = [ { dir: gizmo_screen.xhat_screen, color: "rgb(0, 0, 255)" }, { dir: gizmo_screen.yhat_screen, color: "rgb(0, 180, 0)" }, { dir: gizmo_screen.zhat_screen, color: "rgb(255, 0, 0)" }, ]; for (let direction of directions) { ctx.beginPath(); ctx.strokeStyle = direction.color; ctx.lineWidth = 4; let start = gizmo_screen.screen_space; let end = gizmo_screen.screen_space.add(direction.dir.mul(size)); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.stroke(); } camera.fov = old_fov; } setInterval(step, 1000 / FPS);