initial
This commit is contained in:
commit
6db0286fc1
24 files changed
+668
No files matched your search
+510
@@ -0,0 +1,510 @@
|
||||
"""Render a .blend model with a texture blended over its albedo.
|
||||
|
||||
Every material on every mesh is replaced with a Principled BSDF whose base
|
||||
colour is the model's albedo mixed with the supplied texture, with the model's
|
||||
roughness and normal (bump) maps applied. A light is placed behind the camera.
|
||||
The albedo/roughness/normal images are taken from the model's existing material
|
||||
unless overridden on the command line. Texture alpha is respected: transparent
|
||||
areas keep the albedo.
|
||||
|
||||
By default --texture is a UV texture. With --project it is instead an ordinary
|
||||
picture (e.g. a face) that is stretched over the model's silhouette as seen
|
||||
from the camera, projected onto the visible side (leaving the stem as-is) and
|
||||
baked into the UV layout first (save that with --save-projection).
|
||||
|
||||
Usage:
|
||||
python render_model.py MODEL.blend --texture TEXTURE.png --output out.png [options]
|
||||
or directly:
|
||||
blender -b --disable-autoexec MODEL.blend --python render_model.py -- \
|
||||
--texture TEXTURE.png --output out.png [options]
|
||||
|
||||
Options include --blend-mode MIX|MULTIPLY|OVERLAY|..., --blend-factor 0-1,
|
||||
--project/--project-size/--project-offset-x/-y/--stem-max-width, --albedo/--roughness/--normal
|
||||
overrides, --light-strength, --light-left/--light-up, --ambient, --unlit,
|
||||
--azimuth, --elevation, --transparent; see --help.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
try:
|
||||
import bmesh
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
except ImportError:
|
||||
# Run with plain Python: relaunch inside Blender.
|
||||
# python render_model.py MODEL.blend --texture TEX --output OUT [...]
|
||||
if len(sys.argv) < 2 or not sys.argv[1].endswith(".blend"):
|
||||
sys.exit("usage: python render_model.py MODEL.blend --texture TEX --output OUT [options]")
|
||||
blender = os.environ.get("BLENDER", shutil.which("blender"))
|
||||
if not blender:
|
||||
sys.exit("blender not found on PATH (or set $BLENDER)")
|
||||
os.execv(blender, [blender, "-b", "--disable-autoexec", sys.argv[1],
|
||||
"--python", os.path.abspath(__file__), "--", *sys.argv[2:]])
|
||||
|
||||
BLEND_MODES = ["MIX", "MULTIPLY", "OVERLAY", "SCREEN", "SOFT_LIGHT", "ADD",
|
||||
"DARKEN", "LIGHTEN", "COLOR", "HUE", "SATURATION", "VALUE"]
|
||||
|
||||
|
||||
def parse_args():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
p = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--texture", required=True, help="image blended over the albedo")
|
||||
p.add_argument("--output", required=True, help="output image path (PNG)")
|
||||
p.add_argument("--blend-mode", default="MIX", choices=BLEND_MODES,
|
||||
help="how --texture combines with the albedo")
|
||||
p.add_argument("--blend-factor", type=float, default=0.5,
|
||||
help="0 = albedo only, 1 = full --texture effect")
|
||||
|
||||
g = p.add_argument_group("projection")
|
||||
g.add_argument("--project", action="store_true",
|
||||
help="project --texture onto the model from the camera direction "
|
||||
"instead of using it as a UV texture")
|
||||
g.add_argument("--project-azimuth", type=float,
|
||||
help="projection azimuth (default: --azimuth)")
|
||||
g.add_argument("--project-elevation", type=float,
|
||||
help="projection elevation (default: --elevation)")
|
||||
g.add_argument("--project-size", type=float, default=1.0,
|
||||
help="projected size relative to the model's silhouette (1 = stretched to fill it)")
|
||||
g.add_argument("--project-offset-x", type=float, default=0.0,
|
||||
help="shift right, as a fraction of the silhouette width")
|
||||
g.add_argument("--project-offset-y", type=float, default=0.0,
|
||||
help="shift up, as a fraction of the silhouette height")
|
||||
g.add_argument("--stem-max-width", type=float, default=0.12,
|
||||
help="UV islands in the top half narrower than this fraction of the model's "
|
||||
"width count as the stem and keep the albedo (0 = no stem detection)")
|
||||
g.add_argument("--project-resolution", type=int, default=1024,
|
||||
help="baked UV texture size in px")
|
||||
g.add_argument("--save-projection", help="also save the baked UV texture to this PNG")
|
||||
|
||||
p.add_argument("--albedo", help="albedo image (default: from the model's material)")
|
||||
p.add_argument("--roughness", help="roughness image (default: from the model's material)")
|
||||
p.add_argument("--normal", help="OpenGL normal map (default: from the model's material)")
|
||||
p.add_argument("--normal-strength", type=float, default=1.0)
|
||||
p.add_argument("--light-strength", type=float, default=3.0,
|
||||
help="light brightness, roughly sun-equivalent W/m^2 at the model")
|
||||
p.add_argument("--light-left", type=float, default=35.0,
|
||||
help="degrees the light is swung to the camera's left (negative = right)")
|
||||
p.add_argument("--light-up", type=float, default=35.0,
|
||||
help="degrees the light is raised above the camera's view axis (negative = down)")
|
||||
p.add_argument("--ambient", type=float, default=0.1,
|
||||
help="uniform fill light strength (0 = none)")
|
||||
p.add_argument("--unlit", action="store_true",
|
||||
help="skip lighting/roughness/normal; emit the blended colour as-is")
|
||||
p.add_argument("--resolution", type=int, default=1024, help="square output size in px")
|
||||
p.add_argument("--samples", type=int, default=64, help="render samples")
|
||||
p.add_argument("--engine", default="CYCLES", choices=["CYCLES", "BLENDER_EEVEE"])
|
||||
p.add_argument("--transparent", action="store_true", help="transparent background")
|
||||
p.add_argument("--background", default="0,0,0", help="background RGB 0-1 if not transparent")
|
||||
p.add_argument("--azimuth", type=float, default=30.0,
|
||||
help="camera rotation about Z in degrees (0 = front, from -Y)")
|
||||
p.add_argument("--elevation", type=float, default=20.0, help="camera elevation in degrees")
|
||||
p.add_argument("--margin", type=float, default=1.15, help="framing margin (>1 = more space)")
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def bounds(meshes):
|
||||
"""Centre, bounding-sphere radius and largest dimension of the meshes."""
|
||||
corners = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box]
|
||||
lo = Vector(min(c[i] for c in corners) for i in range(3))
|
||||
hi = Vector(max(c[i] for c in corners) for i in range(3))
|
||||
return (lo + hi) / 2, (hi - lo).length / 2, max(hi - lo)
|
||||
|
||||
|
||||
def view_basis(azimuth, elevation):
|
||||
"""Direction from the model towards a camera at azimuth/elevation, plus the camera's right/up."""
|
||||
az, el = math.radians(azimuth), math.radians(elevation)
|
||||
direction = Vector((math.cos(el) * math.sin(az), -math.cos(el) * math.cos(az), math.sin(el)))
|
||||
right = (-direction).cross(Vector((0, 0, 1)))
|
||||
if right.length < 1e-6: # looking straight up/down
|
||||
right = Vector((1, 0, 0))
|
||||
right.normalize()
|
||||
return direction, right, right.cross(-direction)
|
||||
|
||||
|
||||
def linked_image(material, socket_name):
|
||||
"""Find the image feeding a Principled BSDF input, looking through one Normal Map node."""
|
||||
if not (material and material.use_nodes):
|
||||
return None
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type != "BSDF_PRINCIPLED" or not node.inputs[socket_name].is_linked:
|
||||
continue
|
||||
src = node.inputs[socket_name].links[0].from_node
|
||||
if src.type == "NORMAL_MAP" and src.inputs["Color"].is_linked:
|
||||
src = src.inputs["Color"].links[0].from_node
|
||||
if src.type == "TEX_IMAGE":
|
||||
return src.image
|
||||
return None
|
||||
|
||||
|
||||
def load_image(path, non_color=False):
|
||||
img = bpy.data.images.load(bpy.path.abspath(path), check_existing=True)
|
||||
if non_color:
|
||||
img.colorspace_settings.name = "Non-Color"
|
||||
return img
|
||||
|
||||
|
||||
def uv_islands(bm, uv_layer):
|
||||
"""Group faces into UV islands (faces sharing an edge with matching UVs)."""
|
||||
def edge_uvs(loop):
|
||||
return {loop[uv_layer].uv.to_tuple(5), loop.link_loop_next[uv_layer].uv.to_tuple(5)}
|
||||
|
||||
seen, islands = set(), []
|
||||
for start in bm.faces:
|
||||
if start.index in seen:
|
||||
continue
|
||||
seen.add(start.index)
|
||||
stack, island = [start], []
|
||||
while stack:
|
||||
face = stack.pop()
|
||||
island.append(face)
|
||||
for loop in face.loops:
|
||||
for other in loop.edge.link_loops:
|
||||
if other.face.index not in seen and edge_uvs(other) == edge_uvs(loop):
|
||||
seen.add(other.face.index)
|
||||
stack.append(other.face)
|
||||
islands.append(island)
|
||||
return islands
|
||||
|
||||
|
||||
def mark_stem(meshes, max_width):
|
||||
"""Store a 'projection_keep' face-corner attribute (0 on the stem) and return non-stem vertices.
|
||||
|
||||
The stem is any UV island in the top half of the model whose horizontal
|
||||
extent is under max_width x the model's width.
|
||||
"""
|
||||
center, _, _ = bounds(meshes)
|
||||
corners = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box]
|
||||
model_width = max(max(c[i] for c in corners) - min(c[i] for c in corners) for i in (0, 1))
|
||||
body_verts = []
|
||||
for obj in meshes:
|
||||
mesh, mw = obj.data, obj.matrix_world
|
||||
stem_faces = set()
|
||||
if max_width > 0 and mesh.uv_layers.active:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
for island in uv_islands(bm, bm.loops.layers.uv.active):
|
||||
pts = [mw @ v.co for f in island for v in f.verts]
|
||||
spread = max(max(p[i] for p in pts) - min(p[i] for p in pts) for i in (0, 1))
|
||||
if spread < max_width * model_width and min(p.z for p in pts) > center.z:
|
||||
stem_faces.update(f.index for f in island)
|
||||
bm.free()
|
||||
print(f"{obj.name}: {len(stem_faces)} stem faces kept as-is")
|
||||
|
||||
keep = mesh.attributes.get("projection_keep") or \
|
||||
mesh.attributes.new("projection_keep", "FLOAT", "CORNER")
|
||||
for poly in mesh.polygons:
|
||||
stem = poly.index in stem_faces
|
||||
for li in poly.loop_indices:
|
||||
keep.data[li].value = 0.0 if stem else 1.0
|
||||
if not stem:
|
||||
body_verts.extend(mw @ mesh.vertices[vi].co for vi in poly.vertices)
|
||||
return body_verts
|
||||
|
||||
|
||||
def bake_projection(scene, meshes, image, args):
|
||||
"""Stretch `image` over the model's silhouette as seen from the camera direction,
|
||||
project it onto the front-facing surfaces (except the stem) and bake to UV RGBA."""
|
||||
azimuth = args.azimuth if args.project_azimuth is None else args.project_azimuth
|
||||
elevation = args.elevation if args.project_elevation is None else args.project_elevation
|
||||
center, _, _ = bounds(meshes)
|
||||
direction, right, up = view_basis(azimuth, elevation)
|
||||
|
||||
# Silhouette bounds of the body (stem excluded) in the projector's right/up plane.
|
||||
body = mark_stem(meshes, args.stem_max_width)
|
||||
xs = [(p - center).dot(right) for p in body]
|
||||
ys = [(p - center).dot(up) for p in body]
|
||||
sil_w, sil_h = max(xs) - min(xs), max(ys) - min(ys)
|
||||
mid_x = (min(xs) + max(xs)) / 2 + args.project_offset_x * sil_w
|
||||
mid_y = (min(ys) + max(ys)) / 2 + args.project_offset_y * sil_h
|
||||
|
||||
mat = bpy.data.materials.new("projection")
|
||||
mat.use_nodes = True
|
||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||
nodes.clear()
|
||||
|
||||
width = sil_w * args.project_size
|
||||
height = sil_h * args.project_size
|
||||
origin = center + right * mid_x + up * mid_y
|
||||
|
||||
geo = nodes.new("ShaderNodeNewGeometry")
|
||||
rel = nodes.new("ShaderNodeVectorMath")
|
||||
rel.operation = "SUBTRACT"
|
||||
rel.inputs[1].default_value = origin
|
||||
links.new(geo.outputs["Position"], rel.inputs[0])
|
||||
|
||||
def image_coord(axis, span):
|
||||
# (p - origin) . axis / span + 0.5
|
||||
dot = nodes.new("ShaderNodeVectorMath")
|
||||
dot.operation = "DOT_PRODUCT"
|
||||
dot.inputs[1].default_value = axis
|
||||
links.new(rel.outputs["Vector"], dot.inputs[0])
|
||||
scale = nodes.new("ShaderNodeMath")
|
||||
scale.operation = "MULTIPLY_ADD"
|
||||
scale.inputs[1].default_value = 1 / span
|
||||
scale.inputs[2].default_value = 0.5
|
||||
links.new(dot.outputs["Value"], scale.inputs[0])
|
||||
return scale.outputs["Value"]
|
||||
|
||||
coord = nodes.new("ShaderNodeCombineXYZ")
|
||||
links.new(image_coord(right, width), coord.inputs["X"])
|
||||
links.new(image_coord(up, height), coord.inputs["Y"])
|
||||
tex = nodes.new("ShaderNodeTexImage")
|
||||
tex.image = image
|
||||
tex.extension = "CLIP"
|
||||
links.new(coord.outputs["Vector"], tex.inputs["Vector"])
|
||||
|
||||
# Only surfaces facing the projector (so it doesn't bleed onto the back), minus the stem.
|
||||
facing = nodes.new("ShaderNodeVectorMath")
|
||||
facing.operation = "DOT_PRODUCT"
|
||||
facing.inputs[1].default_value = direction
|
||||
links.new(geo.outputs["Normal"], facing.inputs[0])
|
||||
fade = nodes.new("ShaderNodeMapRange")
|
||||
fade.inputs["From Min"].default_value = 0.0
|
||||
fade.inputs["From Max"].default_value = 0.05
|
||||
links.new(facing.outputs["Value"], fade.inputs["Value"])
|
||||
keep = nodes.new("ShaderNodeAttribute")
|
||||
keep.attribute_name = "projection_keep"
|
||||
visible = nodes.new("ShaderNodeMath")
|
||||
visible.operation = "MULTIPLY"
|
||||
links.new(fade.outputs["Result"], visible.inputs[0])
|
||||
links.new(keep.outputs["Fac"], visible.inputs[1])
|
||||
mask = nodes.new("ShaderNodeMath")
|
||||
mask.operation = "MULTIPLY"
|
||||
links.new(tex.outputs["Alpha"], mask.inputs[0])
|
||||
links.new(visible.outputs["Value"], mask.inputs[1])
|
||||
|
||||
emit = nodes.new("ShaderNodeEmission")
|
||||
out = nodes.new("ShaderNodeOutputMaterial")
|
||||
links.new(emit.outputs["Emission"], out.inputs["Surface"])
|
||||
target = nodes.new("ShaderNodeTexImage") # bake target: the active, unconnected node
|
||||
nodes.active = target
|
||||
|
||||
assign_material(meshes, mat)
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in meshes:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
scene.render.engine = "CYCLES"
|
||||
scene.cycles.samples = 1
|
||||
scene.cycles.device = "CPU"
|
||||
|
||||
res = args.project_resolution
|
||||
|
||||
def bake(source, name, non_color=False):
|
||||
img = bpy.data.images.new(name, res, res)
|
||||
if non_color:
|
||||
img.colorspace_settings.name = "Non-Color"
|
||||
for link in list(emit.inputs["Color"].links):
|
||||
links.remove(link)
|
||||
links.new(source, emit.inputs["Color"])
|
||||
target.image = img
|
||||
bpy.ops.object.bake(type="EMIT", margin=16)
|
||||
px = np.empty(res * res * 4, dtype=np.float32)
|
||||
img.pixels.foreach_get(px)
|
||||
return px
|
||||
|
||||
rgba = bake(tex.outputs["Color"], "bake_color")
|
||||
rgba[3::4] = bake(mask.outputs["Value"], "bake_mask", non_color=True)[0::4]
|
||||
|
||||
result = bpy.data.images.new("projected_texture", res, res, alpha=True)
|
||||
result.pixels.foreach_set(rgba)
|
||||
if args.save_projection:
|
||||
result.filepath_raw = bpy.path.abspath(args.save_projection)
|
||||
result.file_format = "PNG"
|
||||
result.save()
|
||||
print(f"Wrote {result.filepath_raw}")
|
||||
return result
|
||||
|
||||
|
||||
def make_material(args, maps, texture):
|
||||
mat = bpy.data.materials.new("blended_texture")
|
||||
mat.use_nodes = True
|
||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||
nodes.clear()
|
||||
|
||||
uv = nodes.new("ShaderNodeUVMap")
|
||||
|
||||
def image_node(image):
|
||||
tex = nodes.new("ShaderNodeTexImage")
|
||||
tex.image = image
|
||||
links.new(uv.outputs["UV"], tex.inputs["Vector"])
|
||||
return tex
|
||||
|
||||
# Base colour: albedo blended with the user texture.
|
||||
user_tex = image_node(texture)
|
||||
if maps["albedo"]:
|
||||
mix = nodes.new("ShaderNodeMix")
|
||||
mix.data_type = "RGBA"
|
||||
mix.blend_type = args.blend_mode
|
||||
mix.clamp_result = True
|
||||
# Factor = blend factor x texture alpha, so transparent areas keep the albedo.
|
||||
factor = nodes.new("ShaderNodeMath")
|
||||
factor.operation = "MULTIPLY"
|
||||
factor.inputs[1].default_value = args.blend_factor
|
||||
links.new(user_tex.outputs["Alpha"], factor.inputs[0])
|
||||
links.new(factor.outputs["Value"], mix.inputs["Factor"])
|
||||
a = next(s for s in mix.inputs if s.name == "A" and s.type == "RGBA")
|
||||
b = next(s for s in mix.inputs if s.name == "B" and s.type == "RGBA")
|
||||
links.new(image_node(maps["albedo"]).outputs["Color"], a)
|
||||
links.new(user_tex.outputs["Color"], b)
|
||||
color = next(s for s in mix.outputs if s.name == "Result" and s.type == "RGBA")
|
||||
else:
|
||||
print("No albedo found; using --texture alone")
|
||||
color = user_tex.outputs["Color"]
|
||||
|
||||
out = nodes.new("ShaderNodeOutputMaterial")
|
||||
if args.unlit:
|
||||
emit = nodes.new("ShaderNodeEmission")
|
||||
links.new(color, emit.inputs["Color"])
|
||||
links.new(emit.outputs["Emission"], out.inputs["Surface"])
|
||||
return mat
|
||||
|
||||
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
|
||||
links.new(color, bsdf.inputs["Base Color"])
|
||||
if maps["roughness"]:
|
||||
maps["roughness"].colorspace_settings.name = "Non-Color"
|
||||
links.new(image_node(maps["roughness"]).outputs["Color"], bsdf.inputs["Roughness"])
|
||||
if maps["normal"]:
|
||||
maps["normal"].colorspace_settings.name = "Non-Color"
|
||||
nmap = nodes.new("ShaderNodeNormalMap")
|
||||
nmap.inputs["Strength"].default_value = args.normal_strength
|
||||
links.new(image_node(maps["normal"]).outputs["Color"], nmap.inputs["Color"])
|
||||
links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
|
||||
links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
|
||||
return mat
|
||||
|
||||
|
||||
def find_maps(args, meshes):
|
||||
original = next((m for o in meshes for m in o.data.materials if m), None)
|
||||
maps = {}
|
||||
for key, socket, override in [("albedo", "Base Color", args.albedo),
|
||||
("roughness", "Roughness", args.roughness),
|
||||
("normal", "Normal", args.normal)]:
|
||||
maps[key] = load_image(override) if override else linked_image(original, socket)
|
||||
print(f"{key}: {maps[key].filepath if maps[key] else 'none'}")
|
||||
return maps
|
||||
|
||||
|
||||
def assign_material(meshes, mat):
|
||||
for obj in meshes:
|
||||
obj.data.materials.clear()
|
||||
obj.data.materials.append(mat)
|
||||
|
||||
|
||||
def setup_world(scene, transparent, rgb, ambient):
|
||||
# Camera rays see the background colour; everything else sees a uniform
|
||||
# grey fill of strength `ambient`, so the background doesn't light the model.
|
||||
world = bpy.data.worlds.new("render_world")
|
||||
scene.world = world
|
||||
world.use_nodes = True
|
||||
nodes, links = world.node_tree.nodes, world.node_tree.links
|
||||
nodes.clear()
|
||||
fill = nodes.new("ShaderNodeBackground")
|
||||
fill.inputs["Strength"].default_value = ambient
|
||||
visible = nodes.new("ShaderNodeBackground")
|
||||
visible.inputs["Color"].default_value = (*rgb, 1.0)
|
||||
path = nodes.new("ShaderNodeLightPath")
|
||||
mix = nodes.new("ShaderNodeMixShader")
|
||||
out = nodes.new("ShaderNodeOutputWorld")
|
||||
links.new(path.outputs["Is Camera Ray"], mix.inputs["Fac"])
|
||||
links.new(fill.outputs["Background"], mix.inputs[1])
|
||||
links.new(visible.outputs["Background"], mix.inputs[2])
|
||||
links.new(mix.outputs["Shader"], out.inputs["Surface"])
|
||||
scene.render.film_transparent = transparent
|
||||
|
||||
|
||||
def frame_camera(scene, meshes, azimuth, elevation, margin):
|
||||
center, radius, _ = bounds(meshes)
|
||||
|
||||
cam_data = bpy.data.cameras.new("render_cam")
|
||||
cam = bpy.data.objects.new("render_cam", cam_data)
|
||||
scene.collection.objects.link(cam)
|
||||
scene.camera = cam
|
||||
|
||||
# Distance so the bounding sphere fits the field of view.
|
||||
fov = cam_data.angle
|
||||
dist = radius * margin / math.sin(fov / 2)
|
||||
direction, _, _ = view_basis(azimuth, elevation)
|
||||
cam.location = center + direction * dist
|
||||
cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler()
|
||||
cam_data.clip_start = dist / 100
|
||||
cam_data.clip_end = dist * 10
|
||||
return center, dist, radius
|
||||
|
||||
|
||||
def add_light_behind_camera(scene, center, cam_dist, radius, args):
|
||||
# Point light behind the camera, swung left/up (in the camera's frame) around the model.
|
||||
direction, right, up = view_basis(args.azimuth, args.elevation)
|
||||
a, b = math.radians(args.light_left), math.radians(args.light_up)
|
||||
direction = (direction * math.cos(a) - right * math.sin(a)) * math.cos(b) + up * math.sin(b)
|
||||
|
||||
light_dist = cam_dist * 1.5
|
||||
data = bpy.data.lights.new("camera_light", "POINT")
|
||||
data.shadow_soft_size = radius * 0.5
|
||||
# Blender point light power P gives ~P / (4 pi^2 d^2) W/m^2 at distance d.
|
||||
data.energy = args.light_strength * 4 * math.pi ** 2 * light_dist ** 2
|
||||
light = bpy.data.objects.new("camera_light", data)
|
||||
light.location = center + direction * light_dist
|
||||
scene.collection.objects.link(light)
|
||||
|
||||
|
||||
def setup_render(scene, args):
|
||||
scene.render.engine = args.engine
|
||||
if args.engine == "CYCLES":
|
||||
scene.cycles.samples = args.samples
|
||||
scene.cycles.use_denoising = False
|
||||
scene.cycles.device = "CPU"
|
||||
else:
|
||||
scene.eevee.taa_render_samples = args.samples
|
||||
|
||||
r = scene.render
|
||||
r.resolution_x = r.resolution_y = args.resolution
|
||||
r.resolution_percentage = 100
|
||||
r.image_settings.file_format = "PNG"
|
||||
r.image_settings.color_mode = "RGBA" if args.transparent else "RGB"
|
||||
r.filepath = bpy.path.abspath(args.output)
|
||||
|
||||
# "Standard" keeps texture colours faithful (Filmic/AgX would remap them).
|
||||
scene.view_settings.view_transform = "Standard"
|
||||
scene.view_settings.look = "None"
|
||||
scene.view_settings.exposure = 0.0
|
||||
scene.view_settings.gamma = 1.0
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
scene = bpy.context.scene
|
||||
|
||||
meshes = [o for o in scene.objects if o.type == "MESH" and o.visible_get()]
|
||||
if not meshes:
|
||||
sys.exit("No visible mesh objects in scene")
|
||||
|
||||
# Only the lights/camera this script adds should be used.
|
||||
for o in scene.objects:
|
||||
if o.type in {"LIGHT", "CAMERA"}:
|
||||
o.hide_render = True
|
||||
|
||||
maps = find_maps(args, meshes)
|
||||
texture = load_image(args.texture)
|
||||
if args.project:
|
||||
texture = bake_projection(scene, meshes, texture, args)
|
||||
assign_material(meshes, make_material(args, maps, texture))
|
||||
setup_world(scene, args.transparent, [float(v) for v in args.background.split(",")],
|
||||
0.0 if args.unlit else args.ambient)
|
||||
center, dist, radius = frame_camera(scene, meshes, args.azimuth, args.elevation, args.margin)
|
||||
if not args.unlit:
|
||||
add_light_behind_camera(scene, center, dist, radius, args)
|
||||
setup_render(scene, args)
|
||||
|
||||
bpy.ops.render.render(write_still=True)
|
||||
print(f"Wrote {scene.render.filepath}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user