Eccentric Developments


Building Bounding Volume Hierarchies With Octrees

In the previous article I explained how the Bounding Volume Hierarchies (BVH) work and why they help speeding up ray-object intersections in a scene. At the end of the whole implementation, the path tracer I have been implementing, improved its speed significantly, but there was a catch...

The BVH building algorithm was, to put is simply, naïve; it divided the scene in halves recursively but disregarded any partition balancing.

This is not ideal, because it doesn't make any effort in order to reduce overlapping between the bounding boxes (BBs). Overlapping is undesirable as it will increase the amount of intersection tests that will have to be calculated.

A good BVH reduces the overlapping of BBs as much as possible, but building them is more complicated. There are multiple ways to build BVHs that approach the optimal structure, using sorting, surface heuristics and the one that I like to use which relies on octrees.

Now I know what you are thinking, "what is an octree?" An octree is a subdivision of 3D space in eight sectors, basically, a cube, composed of eight smaller cubes.

The octree is used to divide and group in sections the triangles in the scene, not dissimilar to being sorted using a bucket sort algorithm, and then use the octree nodes to determine the best space subdivision.

The high-level description of this way to build the BVH is as follows:

  1. Calculate the overall BB of the triangles in scope.
  2. If there are four or less triangles, create a leaf and assign the triangles to it, stop the algorithm. Otherwise, continue.
  3. Take the midpoint of the BB and clasify the triangles into each of the eight sections of the octree.
  4. Count the number of triangles in the following six categories: -x/+x, -y/+y, -z/+z.
  5. Calculate the difference between each -/+ categories, i.e. -x/+x = P, -y/+y = Q, -z/+z = R.
  6. Find the smaller number of the differences. As such as if for instance Q is the smaller of the three, it means that the y axis split offers the best balance for the space subdivision.
  7. Create a node for the current overall BB where the left and right children correspond to the -/+ parts of the selected axis.
  8. Populate the left and right children by grouping the triangles assigned to each and recusively calling Step 1.

For the path tracer, after this change is implemented, there is no other update that needs to be done to use this improved BVH. Also, this is not a performance critical part of the renderization process, so we can take some liberties on the algorithm implemented and don't worry too much about the performance.

The updated code is below

function initializeBVH(args) {
  const { scene, triangleIntersect } = args;
  const getComponents = (triangle, idx) => [triangle.pt0[idx], triangle.pt1[idx], triangle.pt2[idx]];
  const getTriangleAABB = (triangle) => ({
    min: [
      Math.min(...getComponents(triangle, 0)),
      Math.min(...getComponents(triangle, 1)),
      Math.min(...getComponents(triangle, 2)),
    ],
    max: [
      Math.max(...getComponents(triangle, 0)),
      Math.max(...getComponents(triangle, 1)),
      Math.max(...getComponents(triangle, 2)),
    ],
    triangles: [triangle],
  });
  const joinAABBs = (a, b) => ({
    min: [Math.min(a.min[0], b.min[0]), Math.min(a.min[1], b.min[1]), Math.min(a.min[2], b.min[2])],
    max: [Math.max(a.max[0], b.max[0]), Math.max(a.max[1], b.max[1]), Math.max(a.max[2], b.max[2])],
    triangles: [...a.triangles, ...b.triangles],
  });

  // Temporarily extend the triangles to add their bounding box
  scene.forEach((triangle) => {
    triangle.aabb = getTriangleAABB(triangle);
  });

  let totalTriangles = 0;

  // Octree-based implementation
  // Recursively divide the space used by the triangles in octants
  // and use them to decide the best space division into two halves
  // stop when the number of triangles to divide is four or less
  const recursiveOctreeBuild = (triangles) => {
    if (!triangles || !triangles.length) return null;

    const aabb = triangles.map(getTriangleAABB).reduce(joinAABBs);

    if (aabb.triangles.length <= 4) {
      totalTriangles += aabb.triangles.length;
      return {
        aabb,
        left: null,
        right: null,
      };
    }

    const center = aabb.min.map((v, i) => (v + aabb.max[i]) / 2);

    // Helper functiont to find the octant a given point falls into
    const calculateSectorNumber = (point) => {
      let sector = 0;
      if (point[0] > center[0]) sector |= 1;
      if (point[1] > center[1]) sector |= 2;
      if (point[2] > center[2]) sector |= 4;
      return sector;
    };

    // Function to collect the number of triangles in a given sector
    // this requires the use of the triangle bounding box centroid
    const sectors = new Array(8).fill(0).map(() => []);
    triangles.forEach((t) => {
      const triangleAABB = t.aabb;
      const triangleCentroid = [
        (triangleAABB.min[0] + triangleAABB.max[0]) / 2,
        (triangleAABB.min[1] + triangleAABB.max[1]) / 2,
        (triangleAABB.min[2] + triangleAABB.max[2]) / 2,
      ];
      const sectorNumber = calculateSectorNumber(triangleCentroid);
      sectors[sectorNumber].push(t);
    });

    const sectorsDifference = (a, b) =>
      Math.abs(
        sectors[a[0]].length +
          sectors[a[1]].length +
          sectors[a[2]].length +
          sectors[a[3]].length -
          sectors[b[0]].length -
          sectors[b[1]].length -
          sectors[b[2]].length -
          sectors[b[3]].length
      );

    const leftX = [0, 2, 4, 6];
    const rightX = [1, 3, 5, 7];
    const leftY = [0, 1, 4, 5];
    const rightY = [2, 3, 6, 7];
    const leftZ = [0, 1, 2, 3];
    const rightZ = [4, 5, 6, 7];

    const xdiff = sectorsDifference(leftX, rightX);

    const ydiff = sectorsDifference(leftY, rightY);

    const zdiff = sectorsDifference(leftZ, rightZ);

    const sectorsTriangles = (a) => [...sectors[a[0]], ...sectors[a[1]], ...sectors[a[2]], ...sectors[a[3]]];

    const minDiff = Math.min(xdiff, ydiff, zdiff);
    let leftSector = leftZ;
    let rightSector = rightZ;
    if (minDiff == xdiff) {
      leftSector = leftX;
      rightSector = rightX;
    } else if (minDiff == ydiff) {
      leftSector = leftY;
      rightSector = rightY;
    }

    return {
      aabb: {
        ...aabb,
        triangles: null,
      },
      left: recursiveOctreeBuild(sectorsTriangles(leftSector)),
      right: recursiveOctreeBuild(sectorsTriangles(rightSector)),
    };
  };

  // Naive implementation
  // Using a recursive function, divide the triangles array in halves and create BVH nodes for them
  // Stop when there are 4 triangles or less
  // return the root of the BVH

  const recursiveNaiveBuild = (triangles) => {
    if (!triangles || !triangles.length) return null;

    const aabb = triangles.map(getTriangleAABB).reduce(joinAABBs);

    if (aabb.triangles.length <= 4) {
      totalTriangles += aabb.triangles.length;
      return {
        aabb,
        left: null,
        right: null,
      };
    }

    const mid = Math.floor(triangles.length / 2);
    const left = recursiveNaiveBuild(triangles.slice(0, mid));
    const right = recursiveNaiveBuild(triangles.slice(mid, triangles.length));

    return {
      aabb,
      left,
      right,
    };
  };

  const bvh = recursiveOctreeBuild(scene);

  // Remove the per-triangle bounding boxes as they are no longer needed
  scene.forEach((triangle) => {
    triangle.aabb = getTriangleAABB(triangle);
  });

  const intersectBvh = (ray, bvh) => {
    if (!bvh) return false;

    let dxi = 1.0 / ray.direction[0];
    let dyi = 1.0 / ray.direction[1];
    let dzi = 1.0 / ray.direction[2];

    let sx = bvh.aabb.min;
    let rsx = bvh.aabb.max;
    if (dxi < 0.0) {
      sx = bvh.aabb.max;
      rsx = bvh.aabb.min;
    }
    let sy = bvh.aabb.min;
    let rsy = bvh.aabb.max;
    if (dyi < 0.0) {
      sy = bvh.aabb.max;
      rsy = bvh.aabb.min;
    }
    let sz = bvh.aabb.min;
    let rsz = bvh.aabb.max;
    if (dzi < 0.0) {
      sz = bvh.aabb.max;
      rsz = bvh.aabb.min;
    }

    let tmin = (sx[0] - ray.origin[0]) * dxi;
    let tymax = (rsy[1] - ray.origin[1]) * dyi;
    if (tmin > tymax) {
      return false;
    }

    let tmax = (rsx[0] - ray.origin[0]) * dxi;
    let tymin = (sy[1] - ray.origin[1]) * dyi;
    if (tymin > tmax) {
      return false;
    }

    tmin = tymin > tmin ? tymin : tmin;
    tmax = tymax < tmax ? tymax : tmax;
    let tzmin = (sz[2] - ray.origin[2]) * dzi;
    let tzmax = (rsz[2] - ray.origin[2]) * dzi;
    return !(tmin > tzmax || tzmin > tmax);
  };

  // recursively find the triangles in the leaves of the BVH that intersect the given ray

  const recTraverseBvh = (ray, acc, node) => {
    // Check if there is a hit
    if (!intersectBvh(ray, node)) return;

    // Check if this is a leaf
    if (!node.left && !node.right) {
      acc.push(...node.aabb.triangles);
      return;
    }

    recTraverseBvh(ray, acc, node.left);
    recTraverseBvh(ray, acc, node.right);
  };

  // To use with the regular rendering pipeline, return a trace function that works like the regular trace one
  const traceBvh = (ray) => {
    const trianglesFound = [];
    recTraverseBvh(ray, trianglesFound, bvh);
    return trianglesFound
      .map((obj) => ({ ...triangleIntersect(ray, obj), obj }))
      .filter(({ hit }) => hit)
      .reduce((acc, intersection) => (intersection.distance < acc.distance ? intersection : acc), {
        hit: false,
        distance: Number.MAX_VALUE,
      });
  };

  return {
    traceBvh,
  };
}

function createScene(args) {
  const {
    vector: { sub, unit, cross },
    memory: { allocStaticFloat32Array, set },
  } = args;

  function rotatePoint([x, y, z], [ax, ay, az]) {
    let sinax = Math.sin(ax);
    let cosax = Math.cos(ax);

    [y, z] = [y * cosax - z * sinax, y * sinax + z * cosax];

    let sinay = Math.sin(ay);
    let cosay = Math.cos(ay);

    [x, z] = [x * cosay + z * sinay, -x * sinay + z * cosay];

    let sinaz = Math.sin(az);
    let cosaz = Math.cos(az);

    [x, y] = [x * cosaz - y * sinaz, x * sinaz + y * cosaz];

    return [x, y, z];
  }

  function translatePoint(point, [x, y, z]) {
    return [point[0] + x, point[1] + y, point[2] + z];
  }

  function createTorus(radius = 1.0, tubeRadius = 0.3, center, rotation, props) {
    const triangles = [];
    const rings = [];
    const ringSegments = 12;
    const tubeSegments = 24;
    const sourceRing = [];

    const ringSegmentRad = (2 * Math.PI) / ringSegments;
    for (let i = 0; i < ringSegments; i++) {
      sourceRing.push(rotatePoint([tubeRadius, 0, 0], [0, 0, ringSegmentRad * i]));
    }

    const tubeSegmentRad = (2 * Math.PI) / tubeSegments;
    for (let i = 0; i < tubeSegments; i++) {
      const ring = structuredClone(sourceRing)
        .map((pt) => translatePoint(pt, [radius, 0, 0]))
        .map((pt) => rotatePoint(pt, [0, tubeSegmentRad * i, 0]))
        .map((pt) => translatePoint(pt, center))
        .map((pt) => rotatePoint(pt, rotation));
      rings.push(ring);
    }

    for (let i = 0; i < tubeSegments; i++) {
      const ni = (i + 1) % tubeSegments;
      for (let j = 0; j < ringSegments; j++) {
        let pt0 = rings[i][j];
        let pt1 = rings[ni][j];
        let pt2 = rings[i][(j + 1) % ringSegments];
        triangles.push({
          pt0,
          pt1,
          pt2,
          ...props,
        });
        pt0 = rings[i][(j + 1) % ringSegments];
        pt1 = rings[ni][j];
        pt2 = rings[ni][(j + 1) % ringSegments];
        triangles.push({
          pt0,
          pt1,
          pt2,
          ...props,
        });
      }
    }

    return triangles;
  }

  function createPlane(width, height, center, rotation, props) {
    const triangles = [];
    let pt0 = [-width / 2, height / 2, 0];
    let pt1 = [width / 2, height / 2, 0];
    let pt2 = [-width / 2, -height / 2, 0];

    pt0 = translatePoint(rotatePoint(pt0, rotation), center);
    pt1 = translatePoint(rotatePoint(pt1, rotation), center);
    pt2 = translatePoint(rotatePoint(pt2, rotation), center);
    triangles.push({
      pt0,
      pt1,
      pt2,
      ...props,
    });

    pt0 = [width / 2, height / 2, 0];
    pt1 = [width / 2, -height / 2, 0];
    pt2 = [-width / 2, -height / 2, 0];
    pt0 = translatePoint(rotatePoint(pt0, rotation), center);
    pt1 = translatePoint(rotatePoint(pt1, rotation), center);
    pt2 = translatePoint(rotatePoint(pt2, rotation), center);
    triangles.push({
      pt0,
      pt1,
      pt2,
      ...props,
    });

    return triangles;
  }

  const scene1 = [
    ...createTorus(4, 2, [0, 0, 0], [Math.PI / 2, 0, 0], { color: [0, 1, 0], isLight: false }),
    ...createPlane(20, 20, [0, 10, 0], [Math.PI / 2, 0, 0], { color: [3, 3, 3], isLight: true }),
    ...createPlane(1000, 1000, [0, -5, 0], [Math.PI / 2, 0, 0], { color: [1, 1, 1], isLight: false }),
  ];

  const scene = scene1.map((obj, id) => {
    const edge0 = sub(obj.pt1, obj.pt0);
    const edge1 = sub(obj.pt2, obj.pt0);
    obj.normal = unit(cross(edge0, edge1));
    obj.centroid = [
      (obj.pt0[0] + obj.pt1[0] + obj.pt2[0]) / 3,
      (obj.pt0[1] + obj.pt1[1] + obj.pt2[1]) / 3,
      (obj.pt0[2] + obj.pt1[2] + obj.pt2[2]) / 3,
    ];

    obj.id = id;
    return obj;
  });

  return {
    scene,
  };
}

function createMemoryFunctions(args) {
  const { wasm } = args;
  const totalAvailable = 1024 * 4;
  const memPtr = wasm.alloc(totalAvailable);
  const memView = new DataView(wasm.memory.buffer, memPtr, totalAvailable);
  let staOffset = 0;
  let dynOffset = 2048; // half of totalAvailable

  function get(A, idx) {
    return memView.getFloat32(A + idx * 4 - memPtr, true);
  }
  function set(A, idx, v) {
    memView.setFloat32(A + idx * 4 - memPtr, v, true);
  }
  const allocFloat32Array = (size) => {
    const byteOffset = memPtr + dynOffset;
    dynOffset += size * 4;
    return byteOffset;
  };

  const allocStaticFloat32Array = (size) => {
    const byteOffset = memPtr + staOffset;
    staOffset += size * 4;
    return byteOffset;
  };
  const free = () => (dynOffset = 2048);
  return {
    memory: {
      allocFloat32Array,
      allocStaticFloat32Array,
      free,
      get,
      set,
    },
  };
}

function createAspectRatioFunction() {
  return {
    aspectRatio: (width, height) => {
      let gcd = width;
      let reminder = height;
      while (reminder != 0) {
        const temp = reminder;
        reminder = gcd % reminder;
        gcd = temp;
      }
      return [width / gcd, height / gcd];
    },
  };
}

function createCamera(args) {
  const { width, height, aspectRatio } = args;
  const [w, h] = aspectRatio(width, height);
  return {
    camera: {
      leftTop: [-w, h + 1, -50.0],
      rightTop: [w, h + 1, -50.0],
      leftBottom: [-w, -h + 1, -50.0],
      eye: [0.0, 0.0, -65.0],
    },
  };
}

function createImageGeometry({ width, height }) {
  return {
    imageGeometry: {
      width,
      height,
    },
  };
}

function createVectorFunctions() {
  const sub = (A, B) => [A[0] - B[0], A[1] - B[1], A[2] - B[2]];
  const add = (A, B) => [A[0] + B[0], A[1] + B[1], A[2] + B[2]];
  const mul = (A, B) => [A[0] * B[0], A[1] * B[1], A[2] * B[2]];
  const dot = (A, B) => A[0] * B[0] + A[1] * B[1] + A[2] * B[2];
  // 135ms
  const scale = (A, s) => [A[0] * s, A[1] * s, A[2] * s];
  const norm = (A) => Math.sqrt(dot(A, A));
  const unit = (A) => scale(A, 1.0 / norm(A));
  const abs = (A) => [Math.abs(A[0]), Math.abs(A[1]), Math.abs(A[2])];
  const maxDimension = (A) => {
    if (A[0] > A[1] && A[0] > A[2]) return 0;
    if (A[1] > A[0] && A[1] > A[3]) return 1;
    return 2;
  };
  const permute = (A, i, j, k) => [A[i], A[j], A[k]];
  const cross = (A, B) => {
    const j = A[1] * B[2] - B[1] * A[2];
    const k = A[2] * B[0] - A[0] * B[2];
    const l = A[0] * B[1] - A[1] * B[0];
    return [j, k, l];
  };
  const vector = {
    sub,
    add,
    mul,
    dot,
    scale,
    norm,
    unit,
    abs,
    maxDimension,
    permute,
    cross,
  };

  return {
    vector,
  };
}

function calculatePrimaryRays(args) {
  const {
    camera: { rightTop, leftTop, leftBottom, eye },
    imageGeometry: { width, height },
    vector: { scale, add, sub, unit },
  } = args;
  const vdu = scale(sub(rightTop, leftTop), 1.0 / width);
  const vdv = scale(sub(leftBottom, leftTop), 1.0 / height);
  const primaryRays = [];
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const pixel = y * width + x;
      const origin = eye;
      const direction = unit(sub(add(add(scale(vdu, x), scale(vdv, y)), leftTop), origin));
      primaryRays[pixel] = {
        pixel,
        origin,
        direction,
      };
    }
  }

  return {
    primaryRays,
  };
}

function createRandomDirectionFunction(args) {
  const {
    vector: { dot, norm },
  } = args;

  const randomDirection = (normal) => {
    const p = [0, 0, 0];
    while (true) {
      p[0] = Math.random() - 0.5;
      p[1] = Math.random() - 0.5;
      p[2] = Math.random() - 0.5;
      const n = 1.0 / norm(p);
      p[0] *= n;
      p[1] *= n;
      p[2] *= n;
      if (dot(p, normal) >= 0) {
        return p;
      }
    }
  };
  return {
    randomDirection,
  };
}

function createTriangleIntersectFunction(args) {
  const { sub, abs, maxDimension, permute, scale, add } = args.vector;
  const triangleIntersect = (ray, triangle) => {
    const { pt0, pt1, pt2, normal } = triangle;
    let pt0_t = sub(pt0, ray.origin);
    let pt1_t = sub(pt1, ray.origin);
    let pt2_t = sub(pt2, ray.origin);
    const k = maxDimension(abs(ray.origin));
    const i = (k + 1) % 3;
    const j = (i + 1) % 3;

    const [pdx, pdy, pdz] = permute(ray.direction, i, j, k);
    const sz = 1.0 / pdz;
    const sx = -pdx * sz;
    const sy = -pdy * sz;

    pt0_t = permute(pt0_t, i, j, k);
    pt1_t = permute(pt1_t, i, j, k);
    pt2_t = permute(pt2_t, i, j, k);

    const pt0_t_0 = pt0_t[0] + sx * pt0_t[2];
    const pt0_t_1 = pt0_t[1] + sy * pt0_t[2];
    const pt0_t_2 = pt0_t[2] * sz;
    const pt1_t_0 = pt1_t[0] + sx * pt1_t[2];
    const pt1_t_1 = pt1_t[1] + sy * pt1_t[2];
    const pt1_t_2 = pt1_t[2] * sz;
    const pt2_t_0 = pt2_t[0] + sx * pt2_t[2];
    const pt2_t_1 = pt2_t[1] + sy * pt2_t[2];
    const pt2_t_2 = pt2_t[2] * sz;

    const e0 = pt1_t_0 * pt2_t_1 - pt1_t_1 * pt2_t_0;
    const e1 = pt2_t_0 * pt0_t_1 - pt2_t_1 * pt0_t_0;
    const e2 = pt0_t_0 * pt1_t_1 - pt0_t_1 * pt1_t_0;

    if ((e0 < 0.0 || e1 < 0.0 || e2 < 0.0) && (e0 > 0.0 || e1 > 0.0 || e2 > 0.0)) {
      return { hit: false };
    }

    const det = e0 + e1 + e2;
    if (det == 0.0) {
      return { hit: false };
    }

    const t_scaled = e0 * pt0_t_2 + e1 * pt1_t_2 + e2 * pt2_t_2;

    if (det < 0.0 && t_scaled >= 0.0) {
      return { hit: false };
    }

    if (det > 0.0 && t_scaled <= 0.0) {
      return { hit: false };
    }

    const t = t_scaled / det;

    if (t > 0.007) {
      const point = add(scale(ray.direction, t), ray.origin);
      return {
        hit: true,
        distance: t,
        point,
        normal,
      };
    }

    return {
      hit: false,
    };
  };

  return {
    triangleIntersect,
  };
}

function createTraceFunction(args) {
  const { scene, triangleIntersect } = args;
  const trace = (ray) =>
    scene
      .map((obj) => ({ ...triangleIntersect(ray, obj), obj }))
      .filter(({ hit }) => hit)
      .reduce((acc, intersection) => (intersection.distance < acc.distance ? intersection : acc), {
        hit: false,
        distance: Number.MAX_VALUE,
      });
  return {
    trace,
  };
}

function createTracePrimaryRaysFunction(args) {
  const { trace, traceBvh, primaryRays, width } = args;
  const traceResults = [];
  const tracePrimaryRays = (section, tileSize) => {
    let idx = 0;
    const startPixel = section;
    const endPixel = section + width * tileSize;
    for (let i = startPixel; i < endPixel; i++) {
      const ray = primaryRays[i];
      traceResults[idx++] = traceBvh(ray);
    }
    return traceResults;
  };
  return {
    tracePrimaryRays,
  };
}

function createGenerateBitmapFunction(args) {
  const {
    shading,
    vector: { mul },
  } = args;

  const generateBitmap = (traceResults, section, bbp) => {
    let idx = section * 4 * 3;
    for (const it of traceResults) {
      let pixel = [0, 0, 0];
      if (it.hit) {
        pixel = it.obj.color;
        if (!it.obj.isLight) {
          const intensity = shading(it.point, it.normal, 0);
          pixel = mul(pixel, intensity);
        }
      }
      bbp.setFloat32(idx, pixel[0], true);
      bbp.setFloat32(idx + 4, pixel[1], true);
      bbp.setFloat32(idx + 8, pixel[2], true);
      idx += 12;
    }
  };
  return { generateBitmap };
}

function createRenderFunction(args) {
  const { tracePrimaryRays, generateBitmap, width, height } = args;
  const totalPixels = width * height;
  const render = (tileSize, bitmap, syncBuffer) => {
    const sync = new Uint32Array(syncBuffer);
    const sectionSize = tileSize * width;
    let section = Atomics.add(sync, 0, sectionSize);
    const bpp = new DataView(bitmap, 0, bitmap.length);
    while (section < totalPixels) {
      const traceResults = tracePrimaryRays(section, tileSize);
      generateBitmap(traceResults, section, bpp);
      section = Atomics.add(sync, 0, sectionSize);
    }
  };
  return { render };
}

function pipeline(fns) {
  return (args) => {
    let acc = { ...args };
    for (const fn of fns) {
      const result = fn(acc);
      acc = { ...acc, ...result };
    }
    return acc;
  };
}

function createShadingFunction(args) {
  const {
    vector: { dot },
    trace,
    traceBvh,
    randomDirection,
  } = args;
  const shading = (shadingPoint, pointNormal, depth) => {
    const color = [0, 0, 0];
    if (depth === 5) {
      return color;
    }
    const origin = [];
    origin[0] = pointNormal[0] * 0.1 + shadingPoint[0];
    origin[1] = pointNormal[1] * 0.1 + shadingPoint[1];
    origin[2] = pointNormal[2] * 0.1 + shadingPoint[2];

    const direction = randomDirection(pointNormal);
    const d = dot(pointNormal, direction);
    const ray = { origin, direction };
    const tr = traceBvh(ray);
    if (!tr.hit) {
      return color; //black up to this point;
    }
    color[0] = tr.obj.color[0] * d;
    color[1] = tr.obj.color[0] * d;
    color[2] = tr.obj.color[0] * d;
    if (!tr.obj.isLight) {
      const ncolor = shading(tr.point, tr.normal, depth + 1);
      color[0] *= ncolor[0];
      color[1] *= ncolor[1];
      color[2] *= ncolor[2];
    }
    return color;
  };

  return {
    shading,
  };
}

module.exports = {
  createScene,
  createMemoryFunctions,
  createAspectRatioFunction,
  createCamera,
  createImageGeometry,
  createVectorFunctions,
  calculatePrimaryRays,
  createRandomDirectionFunction,
  createTriangleIntersectFunction,
  initializeBVH,
  createTraceFunction,
  createTracePrimaryRaysFunction,
  createGenerateBitmapFunction,
  createRenderFunction,
  pipeline,
  createShadingFunction,
};
function workerFunction() {
  async function loadWasm(wasmFile) {
    const {
      instance: { exports: wasm },
    } = await WebAssembly.instantiate(wasmFile, {});
    return wasm;
  }

  function parseFunctions(functions) {
    const fns = {};
    for (const fn of functions) {
      fns[fn[0]] = new Function(`return ${fn[1]}`)();
    }
    return fns;
  }
  let render = null;

  async function init(wasmFile, width, height, functions) {
    const wasm = await loadWasm(wasmFile);
    const fns = parseFunctions(functions);
    const renderingPipeline = fns.pipeline([
      fns.createMemoryFunctions,
      fns.createVectorFunctions,
      fns.createAspectRatioFunction,
      fns.createScene,
      fns.createCamera,
      fns.createImageGeometry,
      fns.createRandomDirectionFunction,
      fns.calculatePrimaryRays,
      fns.createTriangleIntersectFunction,
      fns.initializeBVH,
      fns.createTraceFunction,
      fns.createShadingFunction,
      fns.createTracePrimaryRaysFunction,
      fns.createGenerateBitmapFunction,
      fns.createRenderFunction,
    ]);
    render = renderingPipeline({
      wasm,
      width,
      height,
      sceneSelector: 1,
    }).render;
  }

  this.onmessage = async (msg) => {
    const { data } = msg;
    if (data.operation === 'init') {
      const { wasmFile, width, height, functions } = data;
      await init(wasmFile, width, height, functions);
      this.postMessage(0);
    } else {
      render(data.tileSize, data.bitmap, data.syncBuffer);
      this.postMessage(1);
    }
  };
}

module.exports = workerFunction;
class WorkersPool {
  #blobUrl;
  poolSize;
  maxPoolSize;
  #pool = [];
  results = [];
  constructor(poolSize, maxPoolSize, workerFunction) {
    const workerFunctionString = `(${workerFunction.toString()})()`;
    const blob = new Blob([workerFunctionString], {
      type: 'application/javascript',
    });
    this.#blobUrl = URL.createObjectURL(blob);
    this.poolSize = poolSize;
    this.maxPoolSize = maxPoolSize;
  }

  init(initPayload) {
    this.#pool = [];
    this.poolSize = Math.max(1, this.poolSize);
    return new Promise((resolve) => {
      let workersDone = 0;
      for (let i = 0; i < this.maxPoolSize; i++) {
        const worker = new Worker(this.#blobUrl);
        this.#pool.push(worker);
        worker.onmessage = () => {
          if (++workersDone === this.maxPoolSize) {
            resolve();
          }
        };
        worker.postMessage(initPayload);
      }
    });
  }

  resize(newSize) {
    this.poolSize = Math.max(1, newSize);
  }

  async process(payload) {
    return new Promise((resolve) => {
      let currentJob = 0;
      for (let i = 0; i < this.poolSize; i++) {
        let wrk = this.#pool[i];
        wrk.onmessage = async (msg) => {
          if (++currentJob === this.poolSize) {
            resolve();
          }
        };
        wrk.postMessage(payload);
      }
    });
  }
}

module.exports = WorkersPool;
const workerFunction = require('./worker-function.js');
const WorkersPool = require('./workers-pool.js');
const pathTracerFunctions = require('./path-tracer-functions.js');
const workersPool = new WorkersPool(1, 1 /* navigator.hardwareConcurrency */, workerFunction);

async function renderAndPresent(canvas, frameCount, framesAcc, syncBuffer, sharedBuffer, finalBitmap) {
  const ctx = canvas.getContext('2d');
  const width = canvas.width;
  const renderStart = performance.now();
  const bitmap = new Float32Array(sharedBuffer);
  const sync = new Uint32Array(syncBuffer);
  const tileSize = 8;

  sync[0] = 0;
  await workersPool.process({ tileSize, bitmap: sharedBuffer, syncBuffer });

  for (let i = 0; i < bitmap.length; i += 3) {
    const r = bitmap[i] + (framesAcc[i] || 0);
    const g = bitmap[i + 1] + (framesAcc[i + 1] || 0);
    const b = bitmap[i + 2] + (framesAcc[i + 2] || 0);
    framesAcc[i] = r;
    framesAcc[i + 1] = g;
    framesAcc[i + 2] = b;
    finalBitmap[i / 3] =
      (255 << 24) |
      ((Math.min(b / frameCount, 1) * 255) << 16) |
      ((Math.min(g / frameCount, 1) * 255) << 8) |
      (Math.min(r / frameCount, 1) * 255);
  }
  const imageData = new ImageData(new Uint8ClampedArray(finalBitmap.buffer), width);
  ctx.putImageData(imageData, 0, 0);
  const elapsed = Math.floor(performance.now() - renderStart);
  const elapsedMs = `${elapsed}ms|${(Math.round(10000 / elapsed) / 10).toFixed(1)}fps|${workersPool.poolSize}/${
    workersPool.maxPoolSize
  }threads(${window.adjustPool ? '?' : '!'})`;
  ctx.font = '20px monospace';
  ctx.textBaseline = 'top';
  const measure = ctx.measureText(elapsedMs);
  ctx.fillStyle = '#000000';
  ctx.fillRect(0, 0, measure.width, measure.fontBoundingBoxDescent);
  ctx.fillStyle = '#999999';
  ctx.fillText(elapsedMs, 0, 0);
}

window.running = false;
(async () => {
  const canvas = document.getElementById('canvas-1');
  const width = canvas.width;
  const height = canvas.height;
  const wasmFile = await (await fetch('wasm/vector_simd.wasm')).arrayBuffer();
  await workersPool.init({
    operation: 'init',
    wasmFile,
    width,
    height,
    functions: Object.entries(pathTracerFunctions).map(([name, fn]) => [name, fn.toString()]),
  });
  let frameCount = 0;
  const framesAcc = new Array(width * height * 3);
  const sharedBuffer = new SharedArrayBuffer(width * height * 3 * 4);
  const syncBuffer = new SharedArrayBuffer(4);
  const finalBitmap = new Uint32Array(width * height);
  window.running = true;

  /* START: Auto adjust poolSize */
  const renderTimes = {};
  let renderStart = performance.now();
  window.adjustPool = true;
  /* END: Auto adjust poolSize */

  const animation = async () => {
    frameCount++;
    await renderAndPresent(canvas, frameCount, framesAcc, syncBuffer, sharedBuffer, finalBitmap);
    /* START: Auto adjust poolSize */
    renderTimes[workersPool.poolSize] += performance.now();
    if (window.adjustPool && frameCount % 10 === 0) {
      renderTimes[workersPool.poolSize] = performance.now() - renderStart;
      if (workersPool.poolSize < workersPool.maxPoolSize) {
        await workersPool.resize(workersPool.poolSize + 1);
        renderStart = performance.now();
      } else {
        window.adjustPool = false;
        let fastest = Number.MAX_VALUE;
        let poolSize = 0;
        for (const [size, time] of Object.entries(renderTimes)) {
          if (time < fastest) {
            fastest = time;
            poolSize = size;
          }
        }
        await workersPool.resize(poolSize);
      }
    }
    /* END: Auto adjust poolSize */
    window.running && window.requestAnimationFrame(animation);
  };
  window.requestAnimationFrame(animation);
})();

Results

The following numbers were taken using Safari 17.6, in a M1 MBA running macOS Sonoma (14.6.1) on Low Power Mode:

Method Result
Naïve 1520 ms
Octree 1388 ms
Performance increase: 8.6%

The octree method for building the BVH, offers around 8% better performance against the naïve method. This doesn't look like much, but you have to keep in mind that the scene used so far offers the best case scenario for the naïve method; the objects are grouped around the middle of the scene and they where added to the scene array in a controlled way, in other words, if you pick two contiguoues (from the scene array) triangles, for example: 255 and 256, the odds of they being right next to each other in the scene space, is very high.

Having said that, getting any performance increase is great. And if the scene is esparce, it will offer even better gains. There are other methods to build this, that I might explore in the future.

Closing

Now I am in a bit of a bind, the JavaScript implemtation I've been developing in this series is based in Light (it needs a good README), which is another path tracer I've been working on and off for a long time. The problem is that writting this path tracer in JavaScript is not as fun as I expected, there are some very interesting aspects to it, for example WASM+SIMD is great, but the JavaScript language itself doesn't lend itself to speed optimizations without lots of effort and (very) ugly code.

For this reason, next articles on path tracing will be based on Light, that way I can focus on a single project and spend more time working with WASM. If things do not work as expected I'll go back to the JavaScript path tracer.

Enrique CR - 2024-09-15