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.
Reference the full script on github or at the end of the answer.
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:
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)
scene.ray_cast
function will be replaced with something more intricate. Could you expand on how the GPU Shader functions would help me?
$\endgroup$
energy
value has been multiplied by 100. Replaced the file.
$\endgroup$