435 lines
13 KiB
TypeScript
435 lines
13 KiB
TypeScript
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<Drawable> {
|
|
let objects: Array<Drawable> = [];
|
|
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);
|