From 0c6db707e0f6562841996d4dccecb2ac6d4ffec3 Mon Sep 17 00:00:00 2001 From: jbb01 <32650546+jbb01@users.noreply.github.com> Date: Sun, 4 Aug 2024 19:04:25 +0200 Subject: [PATCH] separate camera from rendering --- .../java/eu/jonahbauer/raytracing/Main.java | 10 +- .../jonahbauer/raytracing/render/Color.java | 26 +++ .../raytracing/render/camera/Camera.java | 22 +++ .../{Camera.java => camera/SimpleCamera.java} | 172 ++++++------------ .../raytracing/render/renderer/Renderer.java | 20 ++ .../render/renderer/SimpleRenderer.java | 126 +++++++++++++ 6 files changed, 253 insertions(+), 123 deletions(-) create mode 100644 src/main/java/eu/jonahbauer/raytracing/render/camera/Camera.java rename src/main/java/eu/jonahbauer/raytracing/render/{Camera.java => camera/SimpleCamera.java} (52%) create mode 100644 src/main/java/eu/jonahbauer/raytracing/render/renderer/Renderer.java create mode 100644 src/main/java/eu/jonahbauer/raytracing/render/renderer/SimpleRenderer.java diff --git a/src/main/java/eu/jonahbauer/raytracing/Main.java b/src/main/java/eu/jonahbauer/raytracing/Main.java index eaff8dc..6050070 100644 --- a/src/main/java/eu/jonahbauer/raytracing/Main.java +++ b/src/main/java/eu/jonahbauer/raytracing/Main.java @@ -5,11 +5,12 @@ import eu.jonahbauer.raytracing.material.LambertianMaterial; import eu.jonahbauer.raytracing.material.Material; import eu.jonahbauer.raytracing.material.MetallicMaterial; import eu.jonahbauer.raytracing.math.Vec3; -import eu.jonahbauer.raytracing.render.Camera; import eu.jonahbauer.raytracing.render.Color; import eu.jonahbauer.raytracing.render.ImageFormat; +import eu.jonahbauer.raytracing.render.camera.SimpleCamera; import eu.jonahbauer.raytracing.render.canvas.LiveCanvas; import eu.jonahbauer.raytracing.render.canvas.Image; +import eu.jonahbauer.raytracing.render.renderer.SimpleRenderer; import eu.jonahbauer.raytracing.scene.Hittable; import eu.jonahbauer.raytracing.scene.Scene; import eu.jonahbauer.raytracing.scene.Sphere; @@ -23,13 +24,16 @@ public class Main { public static void main(String[] args) throws IOException { var scene = getScene(); - var camera = Camera.builder() + var camera = SimpleCamera.builder() .withImage(1200, 675) .withPosition(new Vec3(13, 2, 3)) .withTarget(new Vec3(0, 0, 0)) .withFieldOfView(Math.toRadians(20)) .withFocusDistance(10.0) .withBlurAngle(Math.toRadians(0.6)) + .build(); + + var renderer = SimpleRenderer.builder() .withSamplesPerPixel(500) .withMaxDepth(50) .build(); @@ -37,7 +41,7 @@ public class Main { var image = new LiveCanvas(new Image(camera.getWidth(), camera.getHeight())); image.preview(); - camera.render(scene, image); + renderer.render(camera, scene, image); ImageFormat.PNG.write(image, Path.of("scene-" + System.currentTimeMillis() + ".png")); } diff --git a/src/main/java/eu/jonahbauer/raytracing/render/Color.java b/src/main/java/eu/jonahbauer/raytracing/render/Color.java index b56e572..0c77b61 100644 --- a/src/main/java/eu/jonahbauer/raytracing/render/Color.java +++ b/src/main/java/eu/jonahbauer/raytracing/render/Color.java @@ -37,6 +37,32 @@ public record Color(double r, double g, double b) { ); } + public static @NotNull Color average(@NotNull Color current, @NotNull Color next, int index) { + return new Color( + current.r() + (next.r() - current.r()) / index, + current.g() + (next.g() - current.g()) / index, + current.b() + (next.b() - current.b()) / index + ); + } + + public static @NotNull Color gamma(@NotNull Color color, double gamma) { + if (gamma == 1.0) { + return color; + } else if (gamma == 2.0) { + return new Color( + Math.sqrt(color.r()), + Math.sqrt(color.g()), + Math.sqrt(color.b()) + ); + } else { + return new Color( + Math.pow(color.r(), 1 / gamma), + Math.pow(color.g(), 1 / gamma), + Math.pow(color.b(), 1 / gamma) + ); + } + } + public Color { if (r < 0 || r > 1 || g < 0 || g > 1 || b < 0 || b > 1) { throw new IllegalArgumentException("r, g and b must be in the range 0 to 1"); diff --git a/src/main/java/eu/jonahbauer/raytracing/render/camera/Camera.java b/src/main/java/eu/jonahbauer/raytracing/render/camera/Camera.java new file mode 100644 index 0000000..ccac043 --- /dev/null +++ b/src/main/java/eu/jonahbauer/raytracing/render/camera/Camera.java @@ -0,0 +1,22 @@ +package eu.jonahbauer.raytracing.render.camera; + +import eu.jonahbauer.raytracing.math.Ray; +import org.jetbrains.annotations.NotNull; + +public interface Camera { + /** + * {@return the width of this camera in pixels} + */ + int getWidth(); + + /** + * {@return the height of this camera in pixels} + */ + int getHeight(); + + /** + * Casts a ray through the given pixel. + * @return a new ray + */ + @NotNull Ray cast(int x, int y); +} diff --git a/src/main/java/eu/jonahbauer/raytracing/render/Camera.java b/src/main/java/eu/jonahbauer/raytracing/render/camera/SimpleCamera.java similarity index 52% rename from src/main/java/eu/jonahbauer/raytracing/render/Camera.java rename to src/main/java/eu/jonahbauer/raytracing/render/camera/SimpleCamera.java index 1c39833..a8c1f79 100644 --- a/src/main/java/eu/jonahbauer/raytracing/render/Camera.java +++ b/src/main/java/eu/jonahbauer/raytracing/render/camera/SimpleCamera.java @@ -1,19 +1,13 @@ -package eu.jonahbauer.raytracing.render; +package eu.jonahbauer.raytracing.render.camera; -import eu.jonahbauer.raytracing.math.Range; import eu.jonahbauer.raytracing.math.Ray; import eu.jonahbauer.raytracing.math.Vec3; -import eu.jonahbauer.raytracing.render.canvas.Canvas; -import eu.jonahbauer.raytracing.render.canvas.Image; -import eu.jonahbauer.raytracing.scene.Scene; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.IntStream; -public final class Camera { +public final class SimpleCamera implements Camera { // image size private final int width; private final int height; @@ -22,9 +16,6 @@ public final class Camera { private final @NotNull Vec3 origin; // rendering - private final int samplesPerPixel; - private final int maxDepth; - private final double gamma; private final double blurRadius; // internal properties @@ -39,11 +30,11 @@ public final class Camera { return new Builder(); } - public Camera() { - this(new Builder()); + public static @NotNull Camera withDefaults() { + return new Builder().build(); } - private Camera(@NotNull Builder builder) { + private SimpleCamera(@NotNull Builder builder) { this.width = builder.imageWidth; this.height = builder.imageHeight; @@ -53,9 +44,6 @@ public final class Camera { this.origin = builder.position; var direction = (builder.direction == null ? builder.target.minus(builder.position).unit() : builder.direction); - this.samplesPerPixel = builder.samplePerPixel; - this.maxDepth = builder.maxDepth; - this.gamma = builder.gamma; this.blurRadius = Math.tan(0.5 * builder.blurAngle) * builder.focusDistance; // project direction the horizontal plane @@ -74,98 +62,63 @@ public final class Camera { .plus(pixelU.div(2)).plus(pixelV.div(2)); } - public @NotNull Image render(@NotNull Scene scene) { - var image = new Image(width, height); - render(scene, image); - return image; - } - - public void render(@NotNull Scene scene, @NotNull Canvas canvas) { - if (canvas.getWidth() != width || canvas.getHeight() != height) throw new IllegalArgumentException(); - - var lines = new AtomicInteger(); - IntStream.range(0, height).parallel().forEach(y -> { - System.out.println(lines.incrementAndGet()); - for (int x = 0; x < width; x++) { - var r = 0d; - var g = 0d; - var b = 0d; - - for (int i = 0; i < samplesPerPixel; i++) { - var ray = getRay(x, y); - var color = getColor(scene, ray); - r += color.r(); - g += color.g(); - b += color.b(); - } - - canvas.set(x, y, new Color( - Math.pow(r / samplesPerPixel, 1 / gamma), - Math.pow(g / samplesPerPixel, 1 / gamma), - Math.pow(b / samplesPerPixel, 1 / gamma) - )); - } - }); + /** + * {@inheritDoc} + */ + public int getWidth() { + return width; } - private @NotNull Ray getRay(int x, int y) { - var origin = this.origin; - if (blurRadius > 0) { - double bu, bv; - do { - bu = 2 * Math.random() - 1; - bv = 2 * Math.random() - 1; - } while (bu * bu + bv * bv >= 1); - - origin = origin.plus(u.times(blurRadius * bu)).plus(v.times(blurRadius * bv)); - } - - return new Ray(origin, getPixel(x, y).minus(origin)); + /** + * {@inheritDoc} + */ + public int getHeight() { + return height; } - private @NotNull Vec3 getPixel(int x, int y) { + /** + * {@inheritDoc} + */ + public @NotNull Ray cast(int x, int y) { Objects.checkIndex(x, width); Objects.checkIndex(y, height); - double dx = x + Math.random() - 0.5; - double dy = y + Math.random() - 0.5; - return pixel00.plus(pixelU.times(dx)).plus(pixelV.times(dy)); - } - - private @NotNull Color getColor(@NotNull Scene scene, @NotNull Ray ray) { - return getColor(scene, ray, maxDepth); + var origin = getRayOrigin(); + var target = getRayTarget(x, y); + return new Ray(origin, target.minus(origin)); } - private @NotNull Color getColor(@NotNull Scene scene, @NotNull Ray ray, int depth) { - if (depth <= 0) return Color.BLACK; - - var optional = scene.hit(ray, new Range(0.001, Double.POSITIVE_INFINITY)); - if (optional.isPresent()) { - var hit = optional.get(); - var material = hit.material(); - return material.scatter(ray, hit) - .map(scatter -> Color.multiply(scatter.attenuation(), getColor(scene, scatter.ray(), depth - 1))) - .orElse(Color.BLACK); - } else { - return getSkyboxColor(ray); + /** + * {@return the origin for a ray cast by this camera} The ray origin is randomized within a disk of + * radius {@link #blurRadius} centered on the camera position and perpendicular to the direction to simulate depth + * of field. + */ + private @NotNull Vec3 getRayOrigin() { + if (blurRadius <= 0) return origin; + + while (true) { + var du = 2 * Math.random() - 1; + var dv = 2 * Math.random() - 1; + if (du * du + dv * dv >= 1) continue; + + var ru = blurRadius * du; + var rv = blurRadius * dv; + + return new Vec3( + origin.x() + ru * u.x() + rv * v.x(), + origin.y() + ru * u.y() + rv * v.y(), + origin.z() + ru * u.z() + rv * v.z() + ); } } - private static @NotNull Color getSkyboxColor(@NotNull Ray ray) { - // altitude from -pi/2 to pi/2 - var alt = Math.copySign( - Math.acos(ray.direction().withY(0).unit().times(ray.direction().unit())), - ray.direction().y() - ); - return Color.lerp(Color.WHITE, Color.SKY, alt / Math.PI + 0.5); - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; + /** + * {@return the target vector for a ray through the given pixel} The position is randomized within the pixel. + */ + private @NotNull Vec3 getRayTarget(int x, int y) { + double dx = x + Math.random() - 0.5; + double dy = y + Math.random() - 0.5; + return pixel00.plus(pixelU.times(dx)).plus(pixelV.times(dy)); } public static class Builder { @@ -181,9 +134,6 @@ public final class Camera { private double focusDistance = 10; private double blurAngle = 0.0; - private int samplePerPixel = 100; - private int maxDepth = 10; - private double gamma = 2.0; private Builder() {} @@ -235,26 +185,8 @@ public final class Camera { return this; } - public @NotNull Builder withSamplesPerPixel(int samples) { - if (samples <= 0) throw new IllegalArgumentException("samples must be positive"); - this.samplePerPixel = samples; - return this; - } - - public @NotNull Builder withMaxDepth(int depth) { - if (depth <= 0) throw new IllegalArgumentException("depth must be positive"); - this.maxDepth = depth; - return this; - } - - public @NotNull Builder withGamma(double gamma) { - if (gamma <= 0 || !Double.isFinite(gamma)) throw new IllegalArgumentException("gamma must be positive"); - this.gamma = gamma; - return this; - } - - public @NotNull Camera build() { - return new Camera(this); + public @NotNull SimpleCamera build() { + return new SimpleCamera(this); } } diff --git a/src/main/java/eu/jonahbauer/raytracing/render/renderer/Renderer.java b/src/main/java/eu/jonahbauer/raytracing/render/renderer/Renderer.java new file mode 100644 index 0000000..77c829a --- /dev/null +++ b/src/main/java/eu/jonahbauer/raytracing/render/renderer/Renderer.java @@ -0,0 +1,20 @@ +package eu.jonahbauer.raytracing.render.renderer; + +import eu.jonahbauer.raytracing.render.camera.Camera; +import eu.jonahbauer.raytracing.render.canvas.Canvas; +import eu.jonahbauer.raytracing.render.canvas.Image; +import eu.jonahbauer.raytracing.scene.Scene; +import org.jetbrains.annotations.NotNull; + +public interface Renderer { + default @NotNull Image render(@NotNull Camera camera, @NotNull Scene scene) { + var image = new Image(camera.getWidth(), camera.getHeight()); + render(camera, scene, image); + return image; + } + + /** + * Renders the {@code scene} as seen by the {@code camera} to the {@code canvas}. + */ + void render(@NotNull Camera camera, @NotNull Scene scene, @NotNull Canvas canvas); +} diff --git a/src/main/java/eu/jonahbauer/raytracing/render/renderer/SimpleRenderer.java b/src/main/java/eu/jonahbauer/raytracing/render/renderer/SimpleRenderer.java new file mode 100644 index 0000000..36815bb --- /dev/null +++ b/src/main/java/eu/jonahbauer/raytracing/render/renderer/SimpleRenderer.java @@ -0,0 +1,126 @@ +package eu.jonahbauer.raytracing.render.renderer; + +import eu.jonahbauer.raytracing.math.Range; +import eu.jonahbauer.raytracing.math.Ray; +import eu.jonahbauer.raytracing.render.Color; +import eu.jonahbauer.raytracing.render.camera.Camera; +import eu.jonahbauer.raytracing.render.canvas.Canvas; +import eu.jonahbauer.raytracing.scene.Scene; +import org.jetbrains.annotations.NotNull; + +import java.util.function.Function; +import java.util.stream.IntStream; +import java.util.stream.LongStream; + +public final class SimpleRenderer implements Renderer { + private final int samplesPerPixel; + private final int maxDepth; + private final double gamma; + + public static @NotNull Builder builder() { + return new Builder(); + } + + public static @NotNull Renderer withDefaults() { + return new Builder().build(); + } + + private SimpleRenderer(@NotNull Builder builder) { + this.samplesPerPixel = builder.samplesPerPixel; + this.maxDepth = builder.maxDepth; + this.gamma = builder.gamma; + } + + @Override + public void render(@NotNull Camera camera, @NotNull Scene scene, @NotNull Canvas canvas) { + if (canvas.getWidth() != camera.getWidth() || canvas.getHeight() != camera.getHeight()) { + throw new IllegalArgumentException("sizes of camera and canvas are different"); + } + + getPixelStream(camera.getWidth(), camera.getHeight()).parallel().forEach(pixel -> { + var y = (int) (pixel >> 32); + var x = (int) pixel; + + var color = Color.BLACK; + for (int i = 1; i <= samplesPerPixel; i++) { + var ray = camera.cast(x, y); + var c = getColor(scene, ray); + color = Color.average(color, c, i); + } + canvas.set(x, y, Color.gamma(color, gamma)); + }); + } + + /** + * {@return the color of the given ray in the given scene} + */ + private @NotNull Color getColor(@NotNull Scene scene, @NotNull Ray ray) { + return getColor0(scene, ray, maxDepth); + } + + private @NotNull Color getColor0(@NotNull Scene scene, @NotNull Ray ray, int depth) { + if (depth <= 0) return Color.BLACK; + + var optional = scene.hit(ray, new Range(0.001, Double.POSITIVE_INFINITY)); + if (optional.isPresent()) { + var hit = optional.get(); + var material = hit.material(); + return material.scatter(ray, hit) + .map(scatter -> Color.multiply(scatter.attenuation(), getColor0(scene, scatter.ray(), depth - 1))) + .orElse(Color.BLACK); + } else { + return getSkyboxColor(ray); + } + } + + /** + * {@return a stream of the pixels in a canvas with the given size} The pixels {@code x} and {@code y} coordinate + * are encoded in the longs lower and upper 32 bits respectively. + */ + private static @NotNull LongStream getPixelStream(int width, int height) { + return IntStream.range(0, height) + .mapToObj(y -> IntStream.range(0, width).mapToLong(x -> (long) y << 32 | x)) + .flatMapToLong(Function.identity()); + } + + /** + * {@return the color of the skybox for a given ray} The skybox color is a linear gradient based on the altitude of + * the ray above the horizon with {@link Color#SKY} at the top and {@link Color#WHITE} at the bottom. + */ + private static @NotNull Color getSkyboxColor(@NotNull Ray ray) { + // altitude from -pi/2 to pi/2 + var alt = Math.copySign( + Math.acos(ray.direction().withY(0).unit().times(ray.direction().unit())), + ray.direction().y() + ); + return Color.lerp(Color.WHITE, Color.SKY, alt / Math.PI + 0.5); + } + + public static class Builder { + private int samplesPerPixel = 100; + private int maxDepth = 10; + private double gamma = 2.0; + + public @NotNull Builder withSamplesPerPixel(int samples) { + if (samples <= 0) throw new IllegalArgumentException("samples must be positive"); + this.samplesPerPixel = samples; + return this; + } + + public @NotNull Builder withMaxDepth(int depth) { + if (depth <= 0) throw new IllegalArgumentException("depth must be positive"); + this.maxDepth = depth; + return this; + } + + public @NotNull Builder withGamma(double gamma) { + if (gamma <= 0 || !Double.isFinite(gamma)) throw new IllegalArgumentException("gamma must be positive"); + this.gamma = gamma; + return this; + } + + public @NotNull SimpleRenderer build() { + return new SimpleRenderer(this); + } + } +}