Created
June 16, 2019 07:21
-
-
Save pingbird/84377d383c20d056664e80849c5b79e9 to your computer and use it in GitHub Desktop.
Web raytracer in Dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:html'; | |
import 'dart:math'; | |
import 'dart:typed_data'; | |
import 'package:vector_math/vector_math.dart'; | |
// Base class for all objects in a raytraced scene | |
abstract class RTObject { | |
// Get the delta for a ray intersection, null otherwise | |
double intersect(Ray ray); | |
// Calculate final observable color of the hit object | |
Vector3 fill(RTScene scene, Ray ray, double dt, int depth); | |
// The following two vectors are used for light sources | |
Vector3 center; | |
Vector3 emissionColor = Vector3.zero(); | |
} | |
const maxDepth = 3; // Maximum number of times a ray can bounce | |
var bounceOffset = 1e-5; // Offset to prevent a ray from hitting the surface it just came from | |
abstract class BasicRTObject extends RTObject { | |
Vector3 get surfaceColor; | |
Vector3 get emissionColor; | |
double get transparency; | |
double get reflection; | |
// Calculate the normal of a surface at a given ray hit position | |
Vector3 normal(Ray ray, double dt, Vector3 hitPos); | |
Vector3 fill(RTScene scene, Ray ray, double dt, int depth) { | |
var hitPos = ray.origin + ray.direction * dt; | |
var hitNormal = normal(ray, dt, hitPos); | |
// Fake light from bottom | |
var bl = max(0, hitNormal.dot(Vector3(0, -1, 0)) * 0.3); | |
var outColor = Vector3(bl, bl, bl); | |
if (surfaceColor != Vector3.zero()) outColor += scene.traceLight(surfaceColor, hitPos, hitNormal); | |
if ((transparency > 0 || reflection > 0) && depth < maxDepth) { | |
// Attempt at calculating the surface color of a PBR material | |
var facingRatio = -ray.direction.dot(hitNormal); | |
var fresnelEffect = pow(1 - facingRatio, 3); | |
var refldir = (ray.direction - hitNormal * 2 * ray.direction.dot(hitNormal))..normalize(); | |
var nray = Ray.originDirection(hitPos + hitNormal * bounceOffset, refldir); | |
var reflection = scene.traceFill(nray, depth + 1); | |
outColor = ( | |
reflection * max(fresnelEffect * this.reflection, this.reflection) | |
) + (outColor * (1 - this.reflection)); | |
} | |
return outColor + emissionColor; | |
} | |
} | |
class SphereObject extends BasicRTObject { | |
SphereObject(this.center, this.radius, { | |
this.surfaceColor, | |
this.emissionColor, | |
this.transparency = 0.0, | |
this.reflection = 0.0, | |
}) : radiusSqr = radius * radius { | |
surfaceColor ??= Vector3.zero(); | |
emissionColor ??= Vector3.zero(); | |
} | |
Vector3 surfaceColor; | |
Vector3 emissionColor; | |
double transparency; | |
double reflection; | |
Vector3 center; | |
double radius; | |
double radiusSqr; | |
intersect(Ray ray) { | |
// Fast sphere intersection algorithm | |
var pt = center - ray.origin; | |
var dir = pt.dot(ray.direction); | |
if (dir < 0) return null; | |
var d2 = pt.dot(pt) - (dir * dir); | |
if (d2 > radiusSqr) return null; | |
var thc = sqrt(radiusSqr - d2); | |
return dir > thc ? dir - thc : dir + thc; | |
} | |
normal(Ray ray, double dt, Vector3 hitPos) => | |
(hitPos - center)..normalize(); | |
} | |
class RTScene { | |
List<RTObject> objects = []; | |
// Get the fill color for a given ray | |
Vector3 traceFill(Ray ray, int depth) { | |
RTObject hit; | |
double tnear = double.infinity; | |
for (var o in objects) { | |
double near; | |
if ((near = o.intersect(ray)) != null) { | |
if (near < tnear) { | |
tnear = near; | |
hit = o; | |
} | |
} | |
} | |
if (hit == null) return Vector3.zero(); | |
return hit.fill(this, ray, tnear, depth); | |
} | |
// Calculate a surfaces color and search for visible light sources | |
Vector3 traceLight(Vector3 baseColor, Vector3 pos, Vector3 normal) { | |
var outColor = Vector3(0.0, 0.0, 0.0); | |
for (var o in objects) { | |
if (o.emissionColor == Vector3.zero()) continue; | |
var transmission = Vector3(1,1,1); | |
var lightDirection = (o.center - pos)..normalize(); | |
var nray = Ray.originDirection(pos + normal * bounceOffset, lightDirection); | |
for (var io in objects) { | |
if (io is SphereObject && io != o) { | |
if (io.intersect(nray) != null) { | |
transmission = Vector3.zero(); | |
break; | |
} | |
} | |
} | |
outColor += baseColor.clone()..multiply(transmission * max(0, normal.dot(lightDirection)))..multiply(o.emissionColor); | |
} | |
return outColor; | |
} | |
} | |
class RTCamera { | |
RTScene scene; | |
double fov; | |
int width; | |
int height; | |
// Cached constants to speed up ray calculation | |
double invWidth; | |
double invHeight; | |
double aspectratio; | |
double angle; | |
RTCamera(this.scene, {this.fov = 45, this.width, this.height}) { | |
invWidth = 1.0 / width; | |
invHeight = 1.0 / height; | |
aspectratio = width / height; | |
angle = tan(pi * 0.5 * fov / 180); | |
} | |
Vector3 calcPixel(int x, int y) { | |
// Calculate perspective and ray direction for given coords | |
var xx = (2 * ((x + 0.5) * invWidth) - 1) * angle * aspectratio; | |
var yy = (1 - 2 * ((y + 0.5) * invHeight)) * angle; | |
var raydir = Vector3(xx, yy, 1.0)..normalize()..applyQuaternion(Quaternion.euler(0, radians(45), 0)); | |
// Trace the scene | |
return scene.traceFill(Ray.originDirection(Vector3(0.0, 10.0, -22.0), raydir), 0); | |
} | |
} | |
// Convert HSV to linear RGB for the colored orbs | |
Vector3 hslToRgb(h, s, l) { | |
double r, g, b; | |
if (s == 0) { | |
r = g = b = l; | |
} else { | |
double c(double p, double q, double t) { | |
if (t < 0) t += 1; | |
if (t > 1) t -= 1; | |
if (t < 1/6) return p + (q - p) * 6 * t; | |
if (t < 1/2) return q; | |
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; | |
return p; | |
} | |
var q = l < 0.5 ? l * (1 + s) : l + s - l * s; | |
var p = 2 * l - q; | |
r = c(p, q, h + 1/3); | |
g = c(p, q, h); | |
b = c(p, q, h - 1/3); | |
} | |
return Vector3(r, g, b); | |
} | |
double linear2sRGB(double c) => (c <= 0.0031308) ? (c * 12.92) : (1.055 * pow( c, 1.0 / 2.4 ) - 0.055); | |
void main() async { | |
CanvasElement canvas = querySelector("#canvas"); | |
var ctx = canvas.context2D; | |
var scene = RTScene(); | |
// The floor | |
scene.objects.add(SphereObject(Vector3(0.0, -10006, -20), 10000, surfaceColor: Vector3(0.6, 0.6, 0.6))); | |
// The center reflective orb | |
scene.objects.add(SphereObject(Vector3(0.0, -1, -10), 2, surfaceColor: Vector3(0.0, 0.0, 0.0), transparency: 0.0, reflection: 1.0)); | |
// The colorful semi reflective orbs | |
for (int i = 0; i < 10; i++) { | |
var dt = radians(360 * i / 10); | |
scene.objects.add(SphereObject(Vector3(sin(dt) * 4, -1, cos(dt) * 4 - 10), 1, surfaceColor: hslToRgb(i / 10, 0.7, 0.7), reflection: 0.2)); | |
} | |
// The colorful outer matte orbs | |
for (int i = 0; i < 10; i++) { | |
var dt = radians(360 * (i + 0.5) / 10); | |
scene.objects.add(SphereObject(Vector3(sin(dt) * 6, -1, cos(dt) * 6 - 10), 1, surfaceColor: hslToRgb(i / 10, 0.7, 0.7))); | |
} | |
// Red light | |
scene.objects.add(SphereObject(Vector3(-30, 20, -10), 3, surfaceColor: Vector3.zero(), emissionColor: Vector3(1.0, 0.3, 0.3))); | |
// Center white light | |
scene.objects.add(SphereObject(Vector3(0, 20, -10), 3, surfaceColor: Vector3.zero(), emissionColor: Vector3(0.7, 0.7, 0.7))); | |
// Blue light | |
scene.objects.add(SphereObject(Vector3(30, 20, -10), 3, surfaceColor: Vector3.zero(), emissionColor: Vector3(0.3, 0.3, 1.0))); | |
const aa = 2; // Supersampling rate | |
// We render to this internal buffer and then downscale it into the source image | |
var img = ctx.createImageData(canvas.width * aa, canvas.height * aa); | |
var done = Uint8List(img.width * img.height * aa); | |
var imgcv = CanvasElement(width: img.width, height: img.height); | |
var camera = RTCamera(scene, width: img.width, height: img.height); | |
ctx.scale(1 / aa, 1 / aa); // Downscale everything applied to visible canvas | |
for (int i = 0; i < 10; i++) { | |
var step = 1 << (9 - i); // Progressively fill in pixels, increasing in detail | |
var n = 0; | |
for (int y = 0; y < img.height; y += step) { | |
for (int x = 0; x < img.width; x += step) { | |
if (done[x + y * img.width] != 0) continue; | |
done[x + y * img.width] = 1; | |
// Calculate pixel at position | |
var pixel = camera.calcPixel(x, y); | |
// Tonemapping | |
var r = min(255, max(0, (linear2sRGB(pixel.x) * 255).round())); | |
var g = min(255, max(0, (linear2sRGB(pixel.y) * 255).round())); | |
var b = min(255, max(0, (linear2sRGB(pixel.z) * 255).round())); | |
// Fill in block | |
for (int sx = 0; sx < step && x + sx < img.width; sx++) { | |
for (int sy = 0; sy < step && y + sy < img.width; sy++) { | |
int iof = (x + sx + (y + sy) * img.width) * 4; | |
img.data[iof + 0] = r; | |
img.data[iof + 1] = g; | |
img.data[iof + 2] = b; | |
img.data[iof + 3] = 255; | |
} | |
} | |
// Wait for next frame after 16384 pixels to prevent the browser from hanging | |
if (n++ > 0x4000) { | |
n = 0; | |
await window.animationFrame; | |
imgcv.context2D.putImageData(img, 0, 0); | |
ctx.drawImage(imgcv, 0, 0); | |
} | |
} | |
} | |
// Draw the internal buffer to the canvas element, downscaled | |
imgcv.context2D.putImageData(img, 0, 0); | |
ctx.drawImage(imgcv, 0, 0); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment