1
$\begingroup$

I have a complex geometry nodes setup and a script to render out multiple viewlayers as separate images. For context, I'm generating the fixed imagery necessary for previewing a framed canvas product on our website. The script changes some parameters on the geometry nodes for each canvas size, ultimately resulting in 234 images.

According to this answer, I can set the render region like so:

bpy.data.scenes['Scene'].render.border_min_x
bpy.data.scenes['Scene'].render.border_max_x
bpy.data.scenes['Scene'].render.border_min_y
bpy.data.scenes['Scene'].render.border_max_y

I'm not super familiar with scripting in Blender (or even Python itself - more of a C# guy). I guess I need to somehow find all geometry in the scene and project it to the camera near plane, then find the bounding box of all vertices.

The question: How can I programmatically set the render region to crop the final render image to only encompass the geometry in the scene?

$\endgroup$

2 Answers 2

1
$\begingroup$

I discovered there's a utility function called world_to_camera_view. Once I found that, it only took a little more googling to get a result that I was happy with:

import bpy
import bpy_extras

# Setup
scene = bpy.data.scenes['Scene']
cam = bpy.data.objects['Camera']
objs = bpy.data.objects

def update_render_region():
    min_x = 1
    min_y = 1
    max_x = 0
    max_y = 0

    # For whatever reason, update_tag is what you call to update an object's actual display data
    # This is necessary if you've just programmatically updated a geometry nodes modifier property
    for obj in objs:
        obj.update_tag()

    # Now get the evaluated depsgraph. This has to happen after the objects have had update_tag called
    depsgraph = bpy.context.evaluated_depsgraph_get()

    # For every mesh object, find its vertices' positions on the screen
    for obj in objs:
        if obj.type != 'MESH':
            continue
        
        # I'm working with geometry nodes and it needs to be evaluated to
        # actually have any vertices to iterate
        obj = obj.evaluated_get(depsgraph)
        for vert in obj.data.vertices:
            positions = [(obj.matrix_world @ v.co) for v in obj.data.vertices]
            for pos in positions:
                
                # The magic sauce!
                coord = bpy_extras.object_utils.world_to_camera_view(scene, cam, pos)
                min_x = min(min_x, coord[0])
                min_y = min(min_y, coord[1])
                max_x = max(max_x, coord[0])
                max_y = max(max_y, coord[1])

    # Set the render region and enable it
    scene.render.use_border = True
    scene.render.border_min_x = min_x
    scene.render.border_min_y = min_y
    scene.render.border_max_x = max_x
    scene.render.border_max_y = max_y
    scene.render.use_crop_to_border = True

# Do the rendering (fill in your own code here)
for size in sizes:
    frameObj.modifiers['GeometryNodes']['Input_2'] = float(size[0])
    frameObj.modifiers['GeometryNodes']['Input_3'] = float(size[1])
    update_render_region()
    render(size[0], size[1])

# Be a good citizen and reset the render region
# (A better citizen would remember the old render region 😉)
scene.render.use_border = False
scene.render.border_min_x = 0
scene.render.border_min_y = 0
scene.render.border_max_x = 1
scene.render.border_max_y = 1
scene.render.use_crop_to_border = False

I'm mostly a C# programmer so this is probably horrible Python. Use at your own risk 😇

$\endgroup$
1
$\begingroup$

EDIT: This answer tackles this question using extra math steps. OP has found a built-in function bpy_extras.object_utils.world_to_camera_view(scene, obj, coord) to use instead.

I used the concept of "change of basis" from linear algebra. First, I projected the vertices of each object to the plane of the camera frustum. Then, using basis vectors that define the plane of the frustum along with the lower left corner of camera frustum, we can define the projected corner point in terms of these basis vectors and the lower left corner. The stretching scalars of these basis vectors thus tell us what the border min and max values need to be set as.

import bpy
from mathutils import Vector, Matrix
from mathutils.geometry import normal
from numpy import cross, dot

# these need to be true to set border
bpy.context.scene.render.use_border = True
bpy.context.scene.render.use_crop_to_border = True

# get corners of camera frustum
scene = bpy.context.scene
cam = bpy.data.objects['Camera'] 
bpy.context.view_layer.objects.active = cam
camType = bpy.context.object.data.type 
# normalize to ignore camera scale
matrix = cam.matrix_world.normalized()
frame = [matrix @ v for v in cam.data.view_frame(scene=scene)]
origin = matrix.to_translation()
frame.append(origin)

# frame 0 thru 3 are corners of camera frustum
p1, p2, p3, p4 = frame[0:4]

# camera location
l = cam.location

# horizontal basis vector of camera frustum
v1 = p2 - p3
# vertical basis vector of camera frustum
v2 = p4 - p3
# get normal vector of plane
normal = cross(v1, v2)

# objects stored in collection
col = bpy.data.collections['Collection 2']
objs = col.objects

# list of horizontal scalars
s1s = []
# list of vertical scalars
s2s = []

# get each objects coordinates in terms of new basis vectors and p3
for obj in objs:
    verts = [obj.matrix_world @ v.co for v in obj.data.vertices]
    for v in verts:
        # direction of line to be parameterized, if orthogonal, direction should be normal
        if camType == 'ORTHO':
            direction = normal
        else:
            direction = l - v
        # parametric value for projection to camera
        t = -(dot(normal, v) - dot(p1, normal))/dot(normal, direction)
        # vert projected to camera frustum
        vP = v + Vector(t * direction)
        # matrix with v1 and v2 as basis vectors (has to be square in order to invert)      
        mtxB = Matrix((v1, v2)).to_3x3()
        # find scalars to stretch basis vectors to projected point on frustum
        scalars = (vP - p3) @ mtxB.inverted() 
        # horizontal scalar append to all horizontal scalars:
        s1s.append(scalars[0])
        # vertical scalar append to all vertical scalars:
        s2s.append(scalars[1])
 
# mins and maxs       
x_min, y_min, x_max, y_max = min(s1s), min(s2s), max(s1s), max(s2s)

bpy.data.scenes['Scene'].render.border_min_x = x_min
bpy.data.scenes['Scene'].render.border_min_y = y_min
bpy.data.scenes['Scene'].render.border_max_x = x_max
bpy.data.scenes['Scene'].render.border_max_y = y_max

enter image description here

$\endgroup$
2
  • 1
    $\begingroup$ I actually ended up finding a solution on my own (turns out there's a utility function called world_to_camera_view. I've posted it as an answer. Your answer almost answers the question, but not quite, because it doesn't take into account that I'm working with geometry nodes. Also, while bounding boxes would have worked in my specific case because I'm only working with cubes, it wouldn't necessarily work for non-cube geometry (unless the bboxes are aligned to the camera?). $\endgroup$
    – Clonkex
    Mar 9 at 21:51
  • 1
    $\begingroup$ Ain't nothin like getting humbled by a built-in function when you do something the long way! Also, yes, you are right about needing to project each objects vertices instead of each objects bounding box corners, so I used some monkeys instead of cubes. I corrected my post, but still kept it without the built-in function. $\endgroup$ Mar 9 at 22:54

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.