01-31-lights-camera-material.md 11 KB


title: Lights! Camera! Material! tags: coding, python, blender, 3d description: More wrestling with models, but this time with colors category: coding

date: 2024-01-31

In my quest to create plugins for the open source game, Endless Sky, I've run into the very understandable barrier of graphics. See my earlier post on the topic if you want some background on how I ended up here.

Now, the Blender python API is probably really good for writing Blender plugins, but I'm using it basically as a ground-up design language. For that purpose, it's a bit lacking. Notably, the API incorporates UI concepts like "what is currently selected" that are impossible to work around.

But someone suggested I look at Geometry Script which turned out to be not immediately useful to me[^1]. It did, however, provide an avenue of inquiry that lead me to EasyBPY! Which also turned out to be not immediately useful to me[^2]. It did, however, contain a bunch of code that gave me better insight on how to actually get stuff done with python in Blender.

I've gotten further. Still not where I want to be, but forward motion is always welcome. I'm going to talk a bit about materials, then lights and cameras, which will be enough to get to actually rendering.

Materials are what make surfaces visible in Blender, and they can be very complicated. Today, I'm just interested in getting the bare minimum in, so all I'm going for is adding color to my boxes.

All in all, I want five materials. One for the big metal box, and one for the oscilloscope-like screen; then, three for the pop-up buttons: green if it's pressed down, and either blank or red if it's up.

The button materials are all similar, and they look like this:

down_button_mat = bpy.data.materials.new("DownButton")
down_button_mat.diffuse_color = [0.5, 0.95, 0.5, 0.9]
down_button_mat.roughness = 0.8

up_button_mat = bpy.data.materials.new("UpButton")
up_button_mat.diffuse_color = [0.5, 0.5, 0.5, 0.85]
up_button_mat.roughness = 0.8

attn_button_mat = bpy.data.materials.new("AttnButton")
attn_button_mat.diffuse_color = [0.95, 0.5, 0.5, 0.9]
attn_button_mat.roughness = 0.8

"Diffuse color" means, basically, the color of the thing. The four items in the list are RGBa values (red, green, blue, and alpha). To further distinguish the lit buttons, I made the unlit ones slightly more transparent.

"Roughness" impacts how light reflects off the material during rendering, and is in the range 0-1. The higher it is, the less shiny things will be.

Here's how I changed the button loop to add the materials:

for i in [7, 5.5, 4]:
    for j in [6.8, 7.2, 7.6, 8, 8.4, 8.8, 9.2]:
        if random.random() > .6:
            button_location = mathutils.Vector((5.0 - (i/2.5), j, i-.1))
            button_material = down_button_mat
        else:
            button_location = mathutils.Vector((5.2 - (i/2.5), j, i))
            if random.random() > .8:
                button_material = attn_button_mat
            else:
                button_material = up_button_mat

        bpy.ops.mesh.primitive_cube_add(
            size=1,
            location=button_location,
            rotation=monitor_rotation,
            scale=mathutils.Vector((.8, .2, .6)))

        button = bpy.context.active_object
        button.data.materials.append(button_material)

As you can see, it's a little awkward. The return value of primitive_cube_add is a status flag, but we know that it leaves the new thing as the "active object", so we can grab that and mess with it. This pattern (1- ask for something, 2- look for it in the usual places) is all over Blender python coding. Oh, and not for nothing, I added a rotation to the buttons so they line up nicely with the monitor tilt.

The materials for the computer box and monitor are very slightly more complex, but still extremely simple:

monitor_mat = bpy.data.materials.new("ComputerMonitor")
monitor_mat.diffuse_color = [0, 0.02, 0, 1]
monitor_mat.specular_color = [0.9, 1, 0.9]
monitor_mat.roughness = 0.05

metal_mat = bpy.data.materials.new("ComputerMetal")
metal_mat.diffuse_color = [0.3, 0.3, 0.33, 1]
metal_mat.roughness = 0.2
metal_mat.metallic = 0.9

"Specular color" is the color that the surface reflects, and is a simple RGB value. Since I'm going for a retro green-and-black monitor[^3], I want it to reflect green. And I want it to seem smooth and glassy, so "roughness" is very low.

For the metal box, I'm setting something called "metallic", which is a 0-1 range like roughness. Honestly, I don't know exactly what this does, but it's part of the base material object, and I want the box to be metal, so I'm giving it a shot.

Also, note that creating a new material has a different pattern from new scene objects. It returns the new object! But, you can't set properties when instantiating, you have to do it afterwards. Blender is a minefield.

Regardless, I add the materials to the screen and computer box in the same way as with the buttons.

After all of that, I have a computer!

Still simple, but now colorful{width=323 height=223}

Again, moving in baby steps. This is a big change from last time and the all-grey computer.

Finally, if you want to use Blender to actually render something, you need at least one light and at least one camera. On my install of Blender, the "blank document" you get when you start up has a cube, a camera, and a light. And, if you remember last time, the first thing I do is delete all of that (and all of any previous runs of the same script, incidentally).

Endless Sky has some standard Blender templates for creating graphics. Of course, I want to make sure I can handle everything in code, so I load up the template[^4] and carefully note all the info therein. It turns out that there is one camera, and two lights: a "sun" (light in parallel from a single direction, and a "point" (light radiating from a single point). I'm not going to explain all the code, but here's what I came up with:

# add a camera

cam = bpy.data.cameras.new("EndlessCamera")
cam.type = 'ORTHO'
cam.angle = 0.85756
cam.clip_start = 0.100
cam.clip_end = 100.0
cam.lens = 35
cam.ortho_scale = 6.0
cam.sensor_height = 18
cam.sensor_width = 32
cam_obj = bpy.data.objects.new("EndlessCameraObj", cam)
cam_obj.location = mathutils.Vector((-10, -10, 8.2))
cam_obj.rotation_euler = mathutils.Vector((1.047, 0.0, -0.7854))
bpy.context.collection.objects.link(cam_obj)
bpy.context.scene.camera = cam_obj


# light sources
# lamp

bpy.ops.object.add(type='LIGHT', location=(1.568, -3.856, 1.311))
lamp = bpy.context.object
lamp.data.type = 'POINT'
lamp.data.cutoff_distance = 30.0
lamp.data.energy = 2
lamp.data.shadow_buffer_clip_start = 1.00
lamp.data.shadow_soft_size = 1.0
lamp.data.use_shadow = True
lamp.rotation_euler = mathutils.Vector((0.6503271, 0.05521698, 1.866455))

# sun

bpy.ops.object.add(type='LIGHT', location=(0.7973, 7.599, 4.7))
sun = bpy.context.object
sun.data.type='SUN'
sun.data.cutoff_distance = 30.0
sun.data.energy = 2
sun.data.shadow_buffer_clip_start = 1.00
sun.data.shadow_soft_size = 1.0
sun.data.use_shadow = True
sun.rotation_euler = mathutils.Vector((-0.418879, -0.1396263, 0.2094395))

Why does a point light source need a rotation? No idea, but I want to hew as closely as possible to the provided template. Now, I can render!

Glorious!{width=323 height=223}

That ... doesn't look right. It turns out that the camera is located behind the computer, and pointed at the (0,0,0) origin point. Also, I've clearly built the computer much larger than is visible by the camera. In essence, I've spent a bunch of time building up to a render of this computer's gigantic butt.

Fortunately, that time wasn't wasted. It just means I have to move all my stuff. There are a couple ways to do this. Now that I know this about the camera, if I were starting a new outfit, I'd just use different coordinates that actually fit in the render window in the right direction.

But I've already built everything, so I'm going to try to take all of it, and rotate, move, and scale all of it together.

First, let's get all of our scene objects in a single space. That means going back and collecting our button objects when I create them.

buttons = []
# then in the loop:
        button = bpy.context.active_object
        buttons.append(button)
        button.data.materials.append(button_material)

Right. Let's do what we'd do in the UI: select everything and then transform the selection.

box_obj.select_set(True)
monitor_obj.select_set(True)
for b in buttons:
    b.select_set(True)

bpy.ops.transform.resize(
    value=(0.25, 0.25, 0.25)
)

bpy.ops.transform.rotate(
    value=math.radians(90)
)

bpy.ops.transform.translate(
    value=(-2,-5,-3.3)
)

This shows another common Blender python pattern: select then "do stuff" to the selection. Oh, and I should be clear: most of the numbers were arrived at by trial and error. This is certainly not pouring forth from my brain fully-formed.

But it works! Behold the rendered product:

Glorious but not a butt!{width=323 height=223}

Successes: The screen looks smooth and shiny and slightly green, the buttons all have a color and are pressed or not.

Opportunities for growth: The buttons don't seem to be transparent at all, which is probably an error on my end. Also, everything obviously looks very flatly colored, which is expected and understandable.

My next steps are pretty clear at this point. I need to figure out how to make materials that involve textures and images, and how to map those onto scene objects. That's more than enough for today, though.

Thanks for reading along with me.

[^1]: Though I acknowledge that it might be very useful once I hit the third of these blog posts.

[^2]: EasyBPY doesn't provide a simpler abstraction, it just bundles some operations and shortcuts others. The core complexity of Blender is still there, and the wrapper is missing a bunch of functionality, so you still need to wrestle with that complexity.

[^3]: I grew up when home computers either connected to your TV or had green monochrome monitors. Also, the in-game organization that makes this computer is associated with green highlights, so my retro desires mesh with the game aesthetics nicely.

[^4]: Specifically, the "outfit" template, which is for things you can install onto a ship.