2
$\begingroup$

I want to change how light is calculated. Therefore I need my own raytracer. How do I write my own simple raytracer using python?

Answering my own question. Will happily accept improvements & suggestions to the answer.

$\endgroup$
3
$\begingroup$

Reference the full script on github or at the end of the answer.

result

Note, that this will be the wrong approach in most cases. Your own bpy raytracer will lack features and be slow and inefficient. There are other APIs (nvidia link>) which are probably more suited for this purpose.

When testing, use small resolutions and few lamps.


To calculate the colors of an image, for each pixel, we have to:

  1. Shoot a ray and check if it intersects. If it doesn't, this pixel stays at an alpha of 0.
  2. If a ray hit an object, get the hit position.
  3. Send a ray to each lamp in from the hit position. If this new ray hits another object, the lamp is obstructed and will not contribute lighting to this pixel. Otherwise calculate the light contribution (with the lamps color, intensity, falloff and object normal) and add it to the pixel.

I'm going to use numpy to store the pixel values and copy them onto an image at the end. This is faster than manipulating a reference to a blender image.

import bpy
import numpy as np
import mathutils

I will store a reference to the scene, the lamps, and the render settings. The width and height of the image can by directly deduced from the render settings. I will use the sensor_width as a fixed constant and calculate the sensor_height relative to the images dimensions. For 2.8, we have to access the view_layer when using the ray_cast function of the scene.

scn = bpy.context.scene
view_layer = bpy.context.view_layer
cam_mat = scn.camera.matrix_world
cam_loc, _, _ = cam_mat.decompose()
lamps = [ob for ob in bpy.data.objects if ob.type=='LAMP']
cam = scn.camera.data
render = scn.render
horizontal = cam.sensor_width/2
vertical = horizontal/render.resolution_x*render.resolution_y

width = render.resolution_x
height = render.resolution_y

Construct the base image array and the corresponding rays. Read this answer and this post on how blender stores images.

For now we will create a pixel list which looks like this:

[y coordinate][x coordinate][rgba index]

and can by done with numpy

pixels = np.zeros((height, width, 4), dtype=float)

The rays are a grid starting from (0, 0, 0) and pointing downwards. The focal length equals their negative Z coordinate and their x, y range is stored in the sensor settings.

ray_width = np.linspace(-horizontal, horizontal, num=width, dtype=float).reshape(1, -1)
ray_width = np.repeat(ray_width, height, axis = 0)
ray_height = np.linspace(-vertical, vertical, num=height, dtype=float).reshape(-1, 1)
ray_height = np.repeat(ray_height, width, axis = 1)
ray_depth = np.zeros((height, width), dtype=float) - cam.lens

rays = np.stack([ray_width, ray_height, ray_depth], axis = 2)

Each ray will have to be transformed by the camera's transformation matrix and be shot from camera_position in direction of camera_position to transformed_ray.

Now we iterate through all the pixels in the image by x, y coordinates. We get the ray for each pixel and transform it by the camera's matrix_world. If the ray cast hit something, that means this pixel should be colored and visible.

for y in range(height):
    for x in range(width):
        ray = cam_mat @ mathutils.Vector(rays[y, x]) - cam_loc
        result, loc, nor, ind, ob, mat = scn.ray_cast(view_layer, cam_loc, ray)

        if (result):
            intensity = base_intensity[:]
            pixels[y, x] = intensity[0], intensity[1], intensity[2], 255

To make lights influence the pixel's values, check if there is a clear path from the last ray_casts position loc. If yes add this lights color and to the intensity. I used a linear falloff like 1 - (point_light_distance / lamp_light_distance). Multiply this by the result of the dotproduct of the surface normal and the direction to the light. This will stop making only faces which directly face the light receive the full amount of light.

        if (result):
            intensity = base_intensity[:]

            for lamp in lamps:
                dir = lamp.location - loc
                dirn = dir.normalized()

                start = loc + dirn * 1e-4
                hit,_,_,_,_,_ = scn.ray_cast(view_layer, start, dirn)
                if not hit:
                    multiplier = max(0, min(1, 1 - dir.length / lamp.data.distance)) * lamp.data.energy * max(0, dirn.dot(nor))
                    intensity[0] += multiplier * lamp.data.color[0]
                    intensity[1] += multiplier * lamp.data.color[1]
                    intensity[2] += multiplier * lamp.data.color[2]

            pixels[y, x] = intensity[0], intensity[1], intensity[2], 255

Finally, create a new image (or use an existing one) and replace the images pixels list with our flattened pixels array.

img = bpy.data.images.get("name")
if (    (not img) or
        (img.size[0] != width or img.size[1] != height)):
    img = bpy.data.images.new("name", width, height)
img.pixels = pixels.reshape(-1)

Full script

import bpy
import numpy as np
import mathutils

scn = bpy.context.scene
base_intensity = list(scn.world.color)
view_layer = bpy.context.view_layer
cam_mat = scn.camera.matrix_world
cam_loc, _, _ = cam_mat.decompose()
lamps = [ob for ob in scn.objects if ob.type=='LIGHT']
cam = scn.camera.data
render = scn.render
horizontal = cam.sensor_width/2
vertical = horizontal/render.resolution_x*render.resolution_y

width = render.resolution_x
height = render.resolution_y
rays = np.zeros((height, width, 3), dtype=float)

ray_width = np.linspace(-horizontal, horizontal, num=width, dtype=float).reshape(1, -1)
ray_width = np.repeat(ray_width, height, axis = 0)
ray_height = np.linspace(-vertical, vertical, num=height, dtype=float).reshape(-1, 1)
ray_height = np.repeat(ray_height, width, axis = 1)
ray_depth = np.zeros((height, width), dtype=float) - cam.lens

rays = np.stack([ray_width, ray_height, ray_depth], axis = 2)

pixels = np.zeros((height, width, 4), dtype=float)

for y in range(height):
    for x in range(width):
        ray = cam_mat @ mathutils.Vector(rays[y, x]) - cam_loc
        result, loc, nor, ind, ob, mat = scn.ray_cast(view_layer, cam_loc, ray)

        if (result):
            intensity = base_intensity[:]

            for lamp in lamps:
                dir = lamp.location - loc
                dirn = dir.normalized()

                start = loc + dirn * 1e-4
                hit,_,_,_,_,_ = scn.ray_cast(view_layer, start, dirn)
                if not hit:
                    multiplier = max(0, min(1, 1 - dir.length / lamp.data.distance)) * lamp.data.energy * max(0, dirn.dot(nor))
                    intensity[0] += multiplier * lamp.data.color[0]
                    intensity[1] += multiplier * lamp.data.color[1]
                    intensity[2] += multiplier * lamp.data.color[2]

            pixels[y, x] = intensity[0], intensity[1], intensity[2], 255

img = bpy.data.images.get("name")
if (    (not img) or
        (img.size[0] != width or img.size[1] != height)):
    img = bpy.data.images.new("name", width, height)
img.pixels = pixels.reshape(-1)
$\endgroup$
  • 2
    $\begingroup$ Do you think your answer could be ported using docs.blender.org/api/current/… ? $\endgroup$ – lemon Sep 7 '19 at 15:24
  • $\begingroup$ Hi @lemon, I must admit, I'm a little confused, because I don't know anything about the GPU Shader Module. But it seems, in your link elements are created using opengl wrapper which can be shown in the 3D View. In my example no "external" functions are used, and the 3D View is ported to a 2D array (image), with the goal, that the scene.ray_cast function will be replaced with something more intricate. Could you expand on how the GPU Shader functions would help me? $\endgroup$ – Leander Sep 7 '19 at 19:26
  • $\begingroup$ Hi @Leander, I've not used this module for now except few copy paste of the given examples. But when read your subject here, I've though this was an opportunity to go further on it. And yes, raycasts should be done by the outside, maybe. But camera location color, etc. can be given by vertex/index buffers. Will think about it but not sure to have time. $\endgroup$ – lemon Sep 8 '19 at 5:17
  • $\begingroup$ have uploaded you file and for some reason I obtain this:i.stack.imgur.com/yNj1M.jpg Is the light setting different from the images above? $\endgroup$ – lemon Sep 8 '19 at 7:32
  • 1
    $\begingroup$ File wasn't upwards compatible to newest Blender 2.8. The light energy value has been multiplied by 100. Replaced the file. $\endgroup$ – Leander Sep 8 '19 at 8:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.