class Color {
constructor(red, green, blue) {
this.red = red;
this.green = green;
this.blue = blue;
}
static add(c1, c2) {
return new Color(c1.red + c2.red, c1.green + c2.green, c1.blue + c2.blue);
}
static addScalar(c1, s) {
return new Color(c1.red + s, c1.green + s, c1.blue + s).limit();
}
static multiply(c1, c2) {
return new Color(c1.red * c2.red, c1.green * c2.green, c1.blue * c2.blue);
}
static multiplyScalar(c1, f) {
return new Color(c1.red * f, c1.green * f, c1.blue * f);
}
static blend(c1, c2, w) {
return Color.add(
Color.multiplyScalar(c1, 1 - w),
Color.multiplyScalar(c2, w),
);
}
limit() {
this.red = this.red > 0 ? (this.red > 1 ? 1 : this.red) : 0;
this.green = this.green > 0 ? (this.green > 1 ? 1 : this.green) : 0;
this.blue = this.blue > 0 ? (this.blue > 1 ? 1 : this.blue) : 0;
return this;
}
brightness() {
const r = Math.floor(this.red * 255);
const g = Math.floor(this.green * 255);
const b = Math.floor(this.blue * 255);
return (r * 77 + g * 150 + b * 29) >> 8;
}
toString() {
const r = Math.floor(this.red * 255);
const g = Math.floor(this.green * 255);
const b = Math.floor(this.blue * 255);
return `rgb(${r},${g},${b})`;
}
}
class Light {
constructor(position, color) {
this.position = position;
this.color = color;
}
toString() {
return `Light [${this.position}]`;
}
}
class Vector {
static add(v, w) {
return new Vector(w.x + v.x, w.y + v.y, w.z + v.z);
}
static subtract(v, w) {
return new Vector(v.x - w.x, v.y - w.y, v.z - w.z);
}
static multiplyScalar(v, w) {
return new Vector(v.x * w, v.y * w, v.z * w);
}
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
normalize() {
const m = this.magnitude();
return new Vector(this.x / m, this.y / m, this.z / m);
}
negateY() {
this.y *= -1;
}
magnitude() {
return Math.sqrt((this.x * this.x) + (this.y * this.y) + (this.z * this.z));
}
cross(w) {
return new Vector(
-this.z * w.y + this.y * w.z,
this.z * w.x - this.x * w.z,
-this.y * w.x + this.x * w.y,
);
}
dot(w) {
return this.x * w.x + this.y * w.y + this.z * w.z;
}
toString() {
return `Vector [${this.x},${this.y},${this.z}]`;
}
}
class Ray {
constructor(position, direction) {
this.position = position;
this.direction = direction;
}
toString() {
return `Ray [${this.position},${this.direction}]`;
}
}
class Scene {
constructor(camera, background, shapes, lights) {
this.camera = camera;
this.background = background;
this.shapes = shapes;
this.lights = lights;
}
}
class Material {
constructor(reflection, transparency, gloss, hasTexture) {
this.reflection = reflection;
this.transparency = transparency;
this.gloss = gloss;
this.hasTexture = hasTexture;
}
getColor() {
throw new Error("getColor() isn't implemented");
}
toString() {
return `Material [gloss=${this.gloss}, transparency=${this.transparency}, hasTexture=${this.hasTexture}]`;
}
}
class SolidMaterial extends Material {
static defaultColor = new Color(0, 0, 0);
color = SolidMaterial.defaultColor;
constructor(color, reflection, transparency, gloss) {
super(reflection, transparency, gloss, true);
this.color = color;
}
getColor() {
return this.color;
}
toString() {
return `SolidMaterial [gloss=${this.gloss}, transparency=${this.transparency}, hasTexture=${this.hasTexture}]`;
}
}
class ChessboardMaterial extends Material {
constructor(colorEven, colorOdd, reflection, transparency, gloss, density) {
super(reflection, transparency, gloss, true);
this.colorEven = colorEven;
this.colorOdd = colorOdd;
this.density = density;
}
wrapUp(t) {
t %= 2;
if (t < -1) t += 2;
if (t >= 1) t -= 2;
return t;
}
getColor(u, v) {
const t = this.wrapUp(u * this.density) * this.wrapUp(v * this.density);
return t < 0 ? this.colorEven : this.colorOdd;
}
toString() {
return `ChessMaterial [gloss=${this.gloss}, transparency=${this.transparency}, hasTexture=${this.hasTexture}]`;
}
}
class Shape {
constructor(position, material) {
this.position = position;
this.material = material;
}
intersect(ray) {
throw new Error("intersect() isn't implemented");
}
}
class Sphere extends Shape {
constructor(position, material, radius) {
super(position, material);
this.radius = radius;
}
intersect(ray) {
const info = new IntersectionInfo();
info.shape = this;
const dst = Vector.subtract(ray.position, this.position);
const B = dst.dot(ray.direction);
const C = dst.dot(dst) - (this.radius * this.radius);
const D = (B * B) - C;
if (D > 0) {
info.isHit = true;
info.distance = (-B) - Math.sqrt(D);
info.position = Vector.add(ray.position, Vector.multiplyScalar(ray.direction, info.distance));
info.normal = Vector.subtract(info.position, this.position).normalize();
info.color = this.material.getColor(0, 0);
} else {
info.isHit = false;
}
return info;
}
toString() {
return `Sphere [position=${this.position}, radius=${this.radius}]`;
}
}
class Plane extends Shape {
constructor(position, material, d) {
super(position, material);
this.d = d;
}
intersect(ray) {
const info = new IntersectionInfo();
info.shape = this;
const Vd = this.position.dot(ray.direction);
if (Vd === 0) return info;
const t = -(this.position.dot(ray.position) + this.d) / Vd;
if (t <= 0) return info;
info.isHit = true;
info.position = Vector.add(ray.position, Vector.multiplyScalar(ray.direction, t));
info.normal = this.position;
info.distance = t;
if (this.material.hasTexture) {
const vU = new Vector(this.position.y, this.position.z, -this.position.x);
const vV = vU.cross(this.position);
const u = info.position.dot(vU);
const v = info.position.dot(vV);
info.color = this.material.getColor(u, v);
} else {
info.color = this.material.getColor(0, 0);
}
return info;
}
toString() {
return `Plane [${this.position}, d=${this.d}]`;
}
}
class IntersectionInfo {
constructor() {
this.isHit = false;
this.hitCount = 0;
this.shape = null;
this.position = null;
this.normal = null;
this.color = IntersectionInfo.defaultColor;
this.distance = null;
}
toString() {
return `Intersection [${this.position}]`;
}
}
IntersectionInfo.defaultColor = new Color(0, 0, 0);
class Camera {
constructor(position, lookAt, up) {
this.position = position;
this.lookAt = lookAt;
this.up = up;
this.equator = this.lookAt.normalize().cross(this.up);
this.screen = Vector.add(this.position, this.lookAt);
}
getRay(vx, vy) {
const pos = Vector.subtract(
this.screen,
Vector.subtract(
Vector.multiplyScalar(this.equator, vx),
Vector.multiplyScalar(this.up, vy),
),
);
pos.negateY();
const dir = Vector.subtract(pos, this.position);
return new Ray(pos, dir.normalize());
}
toString() {
return `Camera [${this.position}]`;
}
}
class Background {
constructor(color, ambience) {
this.color = color;
this.ambience = ambience;
}
toString() {
return `Background [${this.color}]`;
}
}
class Engine {
constructor(options) {
this.checkNumber = 0;
this.options = {
canvasHeight: 100,
canvasWidth: 100,
pixelWidth: 2,
pixelHeight: 2,
renderDiffuse: false,
renderShadows: false,
renderHighlights: false,
renderReflections: false,
rayDepth: 2,
...options,
};
this.options.canvasHeight /= this.options.pixelHeight;
this.options.canvasWidth /= this.options.pixelWidth;
}
renderScene(scene) {
for (let x = 0; x < this.options.canvasWidth; x++) {
for (let y = 0; y < this.options.canvasHeight; y++) {
const xp = x * 1 / this.options.canvasWidth * 2 - 1;
const yp = y * 1 / this.options.canvasHeight * 2 - 1;
const ray = scene.camera.getRay(xp, yp);
const color = this.getPixelColor(ray, scene);
this.setPixel(x, y, color);
}
}
if (this.checkNumber !== 2321)
throw new Error("Scene rendered incorrectly");
}
getPixelColor(ray, scene) {
const info = this.testIntersection(ray, scene, null);
if (info.isHit)
return this.rayTrace(info, ray, scene, 0);
return scene.background.color;
}
setPixel(x, y, color) {
if (x === y)
this.checkNumber += color.brightness();
}
testIntersection(ray, scene, exclude) {
let hitCount = 0;
let best = new IntersectionInfo();
best.distance = 2000;
for (let i = 0; i < scene.shapes.length; i++) {
const shape = scene.shapes[i];
if (shape !== exclude) {
const info = shape.intersect(ray);
if (info.isHit && info.distance >= 0 && info.distance < best.distance) {
best = info;
hitCount++;
}
}
}
best.hitCount = hitCount;
return best;
}
getReflectionRay(P, N, V) {
const c1 = -N.dot(V);
const R1 = Vector.add(Vector.multiplyScalar(N, 2 * c1), V);
return new Ray(P, R1);
}
rayTrace(info, ray, scene, depth) {
let color = Color.multiplyScalar(info.color, scene.background.ambience);
const shininess = 10 ** (info.shape.material.gloss + 1);
for (let i = 0; i < scene.lights.length; i++) {
const light = scene.lights[i];
const v = Vector.subtract(light.position, info.position).normalize();
if (this.options.renderDiffuse) {
const L = v.dot(info.normal);
if (L > 0) {
color = Color.add(
color,
Color.multiply(
info.color,
Color.multiplyScalar(light.color, L),
),
);
}
}
if (depth <= this.options.rayDepth) {
if (this.options.renderReflections && info.shape.material.reflection > 0) {
const reflectionRay = this.getReflectionRay(info.position, info.normal, ray.direction);
const refl = this.testIntersection(reflectionRay, scene, info.shape);
if (refl.isHit && refl.distance > 0) {
refl.color = this.rayTrace(refl, reflectionRay, scene, depth + 1);
} else {
refl.color = scene.background.color;
}
color = Color.blend(
color,
refl.color,
info.shape.material.reflection,
);
}
}
let shadowInfo = new IntersectionInfo();
if (this.options.renderShadows) {
const shadowRay = new Ray(info.position, v);
shadowInfo = this.testIntersection(shadowRay, scene, info.shape);
if (shadowInfo.isHit && shadowInfo.shape !== info.shape) {
const vA = Color.multiplyScalar(color, 0.5);
const dB = 0.5 * (shadowInfo.shape.material.transparency ** 0.5);
color = Color.addScalar(vA, dB);
}
}
if (this.options.renderHighlights && !shadowInfo.isHit && info.shape.material.gloss > 0) {
const Lv = Vector.subtract(info.shape.position, light.position).normalize();
const E = Vector.subtract(scene.camera.position, info.shape.position).normalize();
const H = Vector.subtract(E, Lv).normalize();
const glossWeight = Math.max(info.normal.dot(H), 0) ** shininess;
color = Color.add(Color.multiplyScalar(light.color, glossWeight), color);
}
}
return color.limit();
}
}
function renderScene() {
const camera = new Camera(
new Vector(0, 0, -15),
new Vector(-0.2, 0, 5),
new Vector(0, 1, 0),
);
const background = new Background(new Color(0.5, 0.5, 0.5), 0.4);
const shapes = [
new Sphere(
new Vector(-1.5, 1.5, 2),
new SolidMaterial(new Color(0, 0.5, 0.5), 0.3, 0, 2),
1.5,
),
new Sphere(
new Vector(1, 0.25, 1),
new SolidMaterial(new Color(0.9, 0.9, 0.9), 0.1, 0, 1.5),
0.5,
),
new Plane(
new Vector(0.1, 0.9, -0.5).normalize(),
new ChessboardMaterial(
new Color(1, 1, 1),
new Color(0, 0, 0),
0.2, 0, 1, 0.7,
),
1.2,
),
];
const lights = [
new Light(
new Vector(5, 10, -1),
new Color(0.8, 0.8, 0.8),
),
new Light(
new Vector(-3, 5, -15),
new Color(0.8, 0.8, 0.8),
),
];
const scene = new Scene(camera, background, shapes, lights);
const raytracer = new Engine({
canvasWidth: 100,
canvasHeight: 100,
pixelWidth: 5,
pixelHeight: 5,
renderDiffuse: true,
renderHighlights: true,
renderShadows: true,
renderReflections: true,
rayDepth: 2,
});
raytracer.renderScene(scene);
}
class Benchmark {
runIteration() {
for (let i = 0; i < 15; ++i)
renderScene();
}
}