每日算法 — 使用java实现光线追踪:光线与球体相交检测与反射折射模拟

概述

光线追踪(Ray Tracing)是计算机图形学中最逼真的渲染技术之一,通过模拟光线在场景中的传播路径来生成图像。本文用Java实现一个基础的光线追踪器,重点讲解光线与球体的相交检测、基于反射定律的镜面反射,以及斯涅尔定律的折射模拟。仅需数百行代码,即可渲染出带有阴影、反射和折射效果的逼真场景。

核心算法设计

1. 三维向量运算基础

光线追踪的几乎所有计算都建立在三维向量运算之上。

public class Vec3 {
    public double x, y, z;

    public Vec3(double x, double y, double z) {
        this.x = x; this.y = y; this.z = z;
    }

    public Vec3 add(Vec3 v) { return new Vec3(x + v.x, y + v.y, z + v.z); }
    public Vec3 sub(Vec3 v) { return new Vec3(x - v.x, y - v.y, z - v.z); }
    public Vec3 mul(double s) { return new Vec3(x * s, y * s, z * s); }
    public Vec3 mul(Vec3 v) { return new Vec3(x * v.x, y * v.y, z * v.z); }
    public double dot(Vec3 v) { return x * v.x + y * v.y + z * v.z; }

    public Vec3 cross(Vec3 v) {
        return new Vec3(
            y * v.z - z * v.y,
            z * v.x - x * v.z,
            x * v.y - y * v.x
        );
    }

    public double length() { return Math.sqrt(x*x + y*y + z*z); }
    public Vec3 normalize() {
        double len = length();
        return len > 0 ? new Vec3(x/len, y/len, z/len) : new Vec3(0,0,0);
    }

    public static Vec3 reflect(Vec3 incident, Vec3 normal) {
        // R = I - 2(I·N)N
        return incident.sub(normal.mul(2 * incident.dot(normal)));
    }

    /**
     * 斯涅尔定律计算折射方向
     * @param etai 入射介质折射率
     * @param etat 折射介质折射率
     */
    public static Vec3 refract(Vec3 incident, Vec3 normal, double etai, double etat) {
        double cosi = Math.max(-1, Math.min(1, incident.dot(normal)));
        Vec3 n = normal;
        if (cosi < 0) { cosi = -cosi; } else {
            n = normal.mul(-1);
            double tmp = etai; etai = etat; etat = tmp;
        }
        double eta = etai / etat;
        double k = 1 - eta * eta * (1 - cosi * cosi);
        if (k < 0) return new Vec3(0,0,0); // 全内反射
        return incident.mul(eta).add(n.mul(eta * cosi - Math.sqrt(k)));
    }
}

2. 光线表示与球体相交检测

光线用起点和方向向量表示。光线与球体的相交检测通过求解二次方程实现。

public class Ray {
    public Vec3 origin;     // 光线起点
    public Vec3 direction;  // 方向向量(需归一化)

    public Ray(Vec3 origin, Vec3 direction) {
        this.origin = origin;
        this.direction = direction.normalize();
    }

    /** 光线在参数t处的点:P(t) = O + tD */
    public Vec3 at(double t) {
        return origin.add(direction.mul(t));
    }
}

public class Sphere {
    public Vec3 center;
    public double radius;
    public Material material;

    public Sphere(Vec3 center, double radius, Material material) {
        this.center = center;
        this.radius = radius;
        this.material = material;
    }

    /**
     * 光线与球体相交检测
     * 方程:|O + tD - C|² = r²
     * 展开为关于t的二次方程:at² + bt + c = 0
     */
    public boolean intersect(Ray ray, double[] tOut) {
        Vec3 oc = ray.origin.sub(center);
        double a = ray.direction.dot(ray.direction);
        double b = 2.0 * oc.dot(ray.direction);
        double c = oc.dot(oc) - radius * radius;
        double discriminant = b * b - 4 * a * c;

        if (discriminant < 0) return false; // 无实根,不相交

        double sqrtDisc = Math.sqrt(discriminant);
        double t = (-b - sqrtDisc) / (2 * a); // 取近点
        if (t < 0.001) {
            t = (-b + sqrtDisc) / (2 * a); // 近点在射线后方,取远点
            if (t < 0.001) return false;
        }
        tOut[0] = t;
        return true;
    }
}

3. 材质与散射模型

定义三种材质类型:漫反射(Lambertian)、金属镜面反射、电介质(玻璃折射)。

public abstract class Material {
    public abstract boolean scatter(Ray rayIn, HitRecord rec, Vec3 attenuation, Ray scattered);
}

/** 漫反射材质(Lambertian) */
public class Lambertian extends Material {
    public Vec3 albedo; // 反射率颜色

    public Lambertian(Vec3 albedo) { this.albedo = albedo; }

    @Override
    public boolean scatter(Ray rayIn, HitRecord rec, Vec3 attenuation, Ray scattered) {
        // 在法线半球内随机散射
        Vec3 target = rec.point.add(rec.normal).add(randomInUnitSphere());
        scattered.origin = rec.point;
        scattered.direction = target.sub(rec.point).normalize();
        attenuation.x = albedo.x; attenuation.y = albedo.y; attenuation.z = albedo.z;
        return true;
    }

    private Vec3 randomInUnitSphere() {
        while (true) {
            Vec3 p = new Vec3(Math.random()*2-1, Math.random()*2-1, Math.random()*2-1);
            if (p.length() < 1) return p;
        }
    }
}

/** 金属材质(镜面反射) */
public class Metal extends Material {
    public Vec3 albedo;
    public double fuzz; // 模糊度(0=完美镜面)

    public Metal(Vec3 albedo, double fuzz) {
        this.albedo = albedo;
        this.fuzz = Math.min(fuzz, 1.0);
    }

    @Override
    public boolean scatter(Ray rayIn, HitRecord rec, Vec3 attenuation, Ray scattered) {
        Vec3 reflected = Vec3.reflect(rayIn.direction, rec.normal);
        scattered.origin = rec.point;
        scattered.direction = reflected.add(randomInUnitSphere().mul(fuzz)).normalize();
        attenuation.x = albedo.x; attenuation.y = albedo.y; attenuation.z = albedo.z;
        return scattered.direction.dot(rec.normal) > 0; // 反射方向必须在法线同侧
    }
}

/** 电介质材质(玻璃折射) */
public class Dielectric extends Material {
    public double ir; // 折射率

    public Dielectric(double ir) { this.ir = ir; }

    @Override
    public boolean scatter(Ray rayIn, HitRecord rec, Vec3 attenuation, Ray scattered) {
        attenuation.x = 1.0; attenuation.y = 1.0; attenuation.z = 1.0;
        double refractionRatio = rec.frontFace ? (1.0 / ir) : ir;

        Vec3 unitDir = rayIn.direction.normalize();
        double cosTheta = Math.min(unitDir.mul(-1).dot(rec.normal), 1.0);
        double sinTheta = Math.sqrt(1.0 - cosTheta * cosTheta);

        boolean cannotRefract = refractionRatio * sinTheta > 1.0;
        Vec3 direction;
        if (cannotRefract || reflectance(cosTheta, refractionRatio) > Math.random()) {
            direction = Vec3.reflect(unitDir, rec.normal); // 反射
        } else {
            direction = Vec3.refract(unitDir, rec.normal, 1.0, refractionRatio); // 折射
        }
        scattered.origin = rec.point;
        scattered.direction = direction.normalize();
        return true;
    }

    /** 菲涅尔反射近似(Schlick近似) */
    private double reflectance(double cosine, double refIdx) {
        double r0 = (1 - refIdx) / (1 + refIdx);
        r0 = r0 * r0;
        return r0 + (1 - r0) * Math.pow(1 - cosine, 5);
    }
}

4. 场景与光线追踪核心循环

public class Scene {
    private List<Sphere> objects = new ArrayList<>();

    public void add(Sphere s) { objects.add(s); }

    /**
     * 遍历场景所有物体,找到光线击中的最近物体
     */
    public boolean hit(Ray ray, double tMin, double tMax, HitRecord rec) {
        HitRecord tempRec = new HitRecord();
        boolean hitAnything = false;
        double closest = tMax;

        for (Sphere obj : objects) {
            double[] t = new double[1];
            if (obj.intersect(ray, t) && t[0] < closest && t[0] > tMin) {
                hitAnything = true;
                closest = t[0];
                tempRec.t = t[0];
                tempRec.point = ray.at(t[0]);
                tempRec.normal = tempRec.point.sub(obj.center).normalize();
                tempRec.frontFace = ray.direction.dot(tempRec.normal) < 0;
                if (!tempRec.frontFace) tempRec.normal = tempRec.normal.mul(-1);
                tempRec.material = obj.material;
            }
        }
        if (hitAnything) {
            rec.copyFrom(tempRec);
        }
        return hitAnything;
    }
}

public class RayTracer {
    private Scene scene;
    private Vec3 background = new Vec3(0.5, 0.7, 1.0); // 天空渐变底色
    private int maxDepth = 50; // 最大递归深度

    /**
     * 递归计算光线颜色
     * 光线击中物体 -> 根据材质散射产生新光线 -> 递归追踪
     */
    public Vec3 trace(Ray ray, int depth) {
        if (depth <= 0) return new Vec3(0, 0, 0);

        HitRecord rec = new HitRecord();
        if (scene.hit(ray, 0.001, Double.MAX_VALUE, rec)) {
            Ray scattered = new Ray(new Vec3(0,0,0), new Vec3(0,0,0));
            Vec3 attenuation = new Vec3(0,0,0);
            if (rec.material.scatter(ray, rec, attenuation, scattered)) {
                Vec3 color = trace(scattered, depth - 1);
                return new Vec3(attenuation.x * color.x,
                                attenuation.y * color.y,
                                attenuation.z * color.z);
            }
            return new Vec3(0, 0, 0);
        }

        // 未击中任何物体,返回天空背景色(线性插值)
        double t = 0.5 * (ray.direction.normalize().y + 1.0);
        Vec3 white = new Vec3(1.0, 1.0, 1.0);
        Vec3 blue = new Vec3(0.5, 0.7, 1.0);
        return white.mul(1.0 - t).add(blue.mul(t));
    }
}

5. 相机与抗锯齿

相机定义了观察视角,抗锯齿通过在每个像素内采样多条射线取平均实现。

public class Camera {
    public Vec3 origin;
    public Vec3 lowerLeftCorner;
    public Vec3 horizontal;
    public Vec3 vertical;

    public Camera(Vec3 lookFrom, Vec3 lookAt, Vec3 vup,
                  double vfov, double aspectRatio) {
        double theta = Math.toRadians(vfov);
        double h = Math.tan(theta / 2);
        double viewportHeight = 2.0 * h;
        double viewportWidth = aspectRatio * viewportHeight;

        Vec3 w = lookFrom.sub(lookAt).normalize();
        Vec3 u = vup.cross(w).normalize();
        Vec3 v = w.cross(u);

        origin = lookFrom;
        horizontal = u.mul(viewportWidth);
        vertical = v.mul(viewportHeight);
        lowerLeftCorner = origin.sub(horizontal.mul(0.5))
                                 .sub(vertical.mul(0.5))
                                 .sub(w);
    }

    /** 根据归一化屏幕坐标生成射线 */
    public Ray getRay(double s, double t) {
        Vec3 dir = lowerLeftCorner.add(horizontal.mul(s))
                                   .add(vertical.mul(t))
                                   .sub(origin);
        return new Ray(origin, dir.normalize());
    }
}

public class Renderer {
    private RayTracer tracer;
    private Camera camera;
    private int samplesPerPixel = 100; // 每像素采样数(抗锯齿)

    public void render(int width, int height, String filename) throws Exception {
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);

        for (int j = height - 1; j >= 0; j--) {
            for (int i = 0; i < width; i++) {
                Vec3 color = new Vec3(0, 0, 0);
                // 超采样抗锯齿:在像素内随机偏移采样
                for (int s = 0; s < samplesPerPixel; s++) {
                    double u = (i + Math.random()) / (width - 1);
                    double v = (j + Math.random()) / (height - 1);
                    Ray ray = camera.getRay(u, v);
                    color = color.add(tracer.trace(ray, 50));
                }
                color = color.mul(1.0 / samplesPerPixel);
                // Gamma校正
                color = new Vec3(Math.sqrt(color.x), Math.sqrt(color.y), Math.sqrt(color.z));
                image.setRGB(i, height - 1 - j, toRgb(color));
            }
        }
        ImageIO.write(image, "png", new File(filename));
    }

    private int toRgb(Vec3 c) {
        int r = (int)(255.999 * Math.min(1, Math.max(0, c.x)));
        int g = (int)(255.999 * Math.min(1, Math.max(0, c.y)));
        int b = (int)(255.999 * Math.min(1, Math.max(0, c.z)));
        return (r << 16) | (g << 8) | b;
    }
}

6. 完整场景构建示例

public class RayTracingMain {
    public static void main(String[] args) throws Exception {
        Scene scene = new Scene();

        // 地面:巨大的漫反射球
        scene.add(new Sphere(new Vec3(0, -100.5, -1), 100,
            new Lambertian(new Vec3(0.8, 0.8, 0.0))));

        // 中心:玻璃球(折射率1.5)
        scene.add(new Sphere(new Vec3(0, 0, -1), 0.5,
            new Dielectric(1.5)));

        // 左侧:漫反射球
        scene.add(new Sphere(new Vec3(-1, 0, -1), 0.5,
            new Lambertian(new Vec3(0.4, 0.2, 0.1))));

        // 右侧:金属球(轻微模糊)
        scene.add(new Sphere(new Vec3(1, 0, -1), 0.5,
            new Metal(new Vec3(0.8, 0.6, 0.2), 0.3)));

        Camera cam = new Camera(
            new Vec3(-2, 2, 1),   // 相机位置
            new Vec3(0, 0, -1),   // 看向原点
            new Vec3(0, 1, 0),    // 上向量
            20,                   // 垂直视场角
            16.0 / 9.0            // 宽高比
        );

        RayTracer tracer = new RayTracer();
        tracer.setScene(scene);
        Renderer renderer = new Renderer(tracer, cam);
        renderer.render(800, 450, "raytraced.png");
    }
}

复杂度分析

算法模块 时间复杂度 空间复杂度 说明
光线-球体相交 O(1) O(1) 求解二次方程
单像素追踪 O(S×D×N) O(D) S=采样数, D=递归深度, N=物体数
整幅图像渲染 O(W×H×S×D×N) O(W×H) W=宽, H=高
菲涅尔反射 O(1) O(1) Schlick近似

总结

光线追踪的核心思想是逆向模拟光线传播:从相机发射射线,追踪其在场景中的反射和折射路径。虽然基础实现的时间复杂度较高,但其物理正确性远超传统光栅化渲染。球体相交检测的二次方程求解、反射向量的镜像公式、以及斯涅尔定律的折射方向计算,构成了光线追踪算法的三大数学支柱。对于Java开发者,本项目是深入理解3D图形学和向量运算的绝佳实践。