646 lines
17 KiB
JavaScript
646 lines
17 KiB
JavaScript
// The ray tracer code in this file is written by Adam Burmister.
|
|
// It is available in its original form from:
|
|
//
|
|
// http://labs.flog.nz.co/raytracer/
|
|
//
|
|
// It has been modified slightly by Google to work as a standalone benchmark,
|
|
// but all the computational code remains untouched.
|
|
|
|
// For JetStream3, this code was rewritten using ES6 classes and private fields,
|
|
// dropping namespaces and Prototype.js class system, as well as slightly refactored.
|
|
// All the computational code still remains untouched.
|
|
|
|
class Color {
|
|
#red = 0;
|
|
#green = 0;
|
|
#blue = 0;
|
|
|
|
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 {
|
|
static #defaultColor = new Color(0, 0, 0);
|
|
|
|
#position = null;
|
|
#color = Light.#defaultColor;
|
|
|
|
constructor(position, color) {
|
|
this.#position = position;
|
|
this.#color = color;
|
|
}
|
|
|
|
get position() { return this.#position; }
|
|
get color() { return this.#color; }
|
|
|
|
toString() {
|
|
return `Light [${this.position}]`;
|
|
}
|
|
}
|
|
|
|
class Vector {
|
|
#x = 0;
|
|
#y = 0;
|
|
#z = 0;
|
|
|
|
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;
|
|
}
|
|
|
|
get x() { return this.#x; }
|
|
get y() { return this.#y; }
|
|
get z() { return this.#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 {
|
|
#position = null;
|
|
#direction = null;
|
|
|
|
constructor(position, direction) {
|
|
this.#position = position;
|
|
this.#direction = direction;
|
|
}
|
|
|
|
get position() { return this.#position; }
|
|
get direction() { return this.#direction; }
|
|
|
|
toString() {
|
|
return `Ray [${this.position},${this.direction}]`;
|
|
}
|
|
}
|
|
|
|
class Scene {
|
|
#camera = null;
|
|
#background = null;
|
|
#shapes = [];
|
|
#lights = [];
|
|
|
|
constructor(camera, background, shapes, lights) {
|
|
this.#camera = camera;
|
|
this.#background = background;
|
|
this.#shapes = shapes;
|
|
this.#lights = lights;
|
|
}
|
|
|
|
get camera() { return this.#camera; }
|
|
get background() { return this.#background; }
|
|
get shapes() { return this.#shapes; }
|
|
get lights() { return this.#lights; }
|
|
}
|
|
|
|
class Material {
|
|
#reflection = 0;
|
|
#transparency = 0;
|
|
#gloss = 0;
|
|
#hasTexture = false;
|
|
|
|
constructor(reflection, transparency, gloss, hasTexture) {
|
|
this.#reflection = reflection;
|
|
this.#transparency = transparency;
|
|
this.#gloss = gloss;
|
|
this.#hasTexture = hasTexture;
|
|
}
|
|
|
|
get reflection() { return this.#reflection; }
|
|
get transparency() { return this.#transparency; }
|
|
get gloss() { return this.#gloss; }
|
|
get hasTexture() { return this.#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 {
|
|
static #defaultColorEven = new Color(0, 0, 0);
|
|
static #defaultColorOdd = new Color(0, 0, 0);
|
|
|
|
#colorEven = ChessboardMaterial.#defaultColorEven;
|
|
#colorOdd = ChessboardMaterial.#defaultColorOdd;
|
|
#density = 1;
|
|
|
|
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 {
|
|
#position = null;
|
|
#material = null;
|
|
|
|
constructor(position, material) {
|
|
this.#position = position;
|
|
this.#material = material;
|
|
}
|
|
|
|
get position() { return this.#position; }
|
|
get material() { return this.#material; }
|
|
|
|
intersect(ray) {
|
|
throw new Error("intersect() isn't implemented");
|
|
}
|
|
}
|
|
|
|
class Sphere extends Shape {
|
|
#radius = 0;
|
|
|
|
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) { // intersection!
|
|
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 {
|
|
#d = 0;
|
|
|
|
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; // no intersection
|
|
|
|
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 {
|
|
static #defaultColor = new Color(0, 0, 0);
|
|
|
|
isHit = false;
|
|
hitCount = 0;
|
|
shape = null;
|
|
position = null;
|
|
normal = null;
|
|
color = IntersectionInfo.#defaultColor;
|
|
distance = null;
|
|
|
|
toString() {
|
|
return `Intersection [${this.position}]`;
|
|
}
|
|
}
|
|
|
|
class Camera {
|
|
#position = null;
|
|
#lookAt = null;
|
|
#up = null;
|
|
#equator = null;
|
|
#screen = null;
|
|
|
|
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);
|
|
}
|
|
|
|
get position() { return this.#position; }
|
|
|
|
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 {
|
|
static #defaultColor = new Color(0, 0, 0);
|
|
|
|
#color = Background.#defaultColor;
|
|
#ambience = 0;
|
|
|
|
constructor(color, ambience) {
|
|
this.#color = color;
|
|
this.#ambience = ambience;
|
|
}
|
|
|
|
get color() { return this.#color; }
|
|
get ambience() { return this.#ambience; }
|
|
|
|
toString() {
|
|
return `Background [${this.color}]`;
|
|
}
|
|
}
|
|
|
|
class Engine {
|
|
// Variable used to hold a number that can be used to verify that
|
|
// the scene was ray traced correctly.
|
|
#checkNumber = 0;
|
|
#options = {};
|
|
|
|
constructor(options) {
|
|
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) {
|
|
// Calc ambient
|
|
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];
|
|
|
|
// Calc diffuse lighting
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// The greater the depth the more accurate the colours, but
|
|
// this is exponentially (!) expensive
|
|
if (depth <= this.#options.rayDepth) {
|
|
// calculate reflection ray
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Render shadows and highlights
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Phong specular highlights
|
|
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();
|
|
}
|
|
}
|