initial
No files matched your search
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,158 @@
|
||||
"""Render a model with render_model.py and compose it into a bottle label:
|
||||
the render on the left, title and subtitle on the right.
|
||||
|
||||
Takes the same arguments as render_model.py (the model path first, then
|
||||
--texture, --project, --azimuth, ...), plus the label options below.
|
||||
--output is an A4 sheet with the label repeated --labels-per-sheet times
|
||||
down the page (PDF or PNG by extension, at --dpi). The render itself is made
|
||||
with a transparent background, trimmed to its visible pixels and scaled to fit
|
||||
the left panel of each label.
|
||||
|
||||
Usage:
|
||||
python make_label.py MODEL.blend --texture TEX --output labels.pdf \
|
||||
[render_model.py options] [--title jider] \
|
||||
[--subtitle "alc 5.5% · mostly maggot-free"] [--labels-per-sheet 4]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
RENDER_MODEL = os.path.join(HERE, "render_model.py")
|
||||
A4_MM = (210, 297)
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""Split argv into label options and the arguments passed on to render_model.py."""
|
||||
p = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="All other options are passed to render_model.py "
|
||||
f"(see: python {os.path.basename(RENDER_MODEL)} "
|
||||
"MODEL.blend --help).")
|
||||
p.add_argument("model", help="the .blend file to render")
|
||||
p.add_argument("--output", required=True, help="output A4 sheet (.pdf or .png)")
|
||||
p.add_argument("--save-render", help="also keep the apple render at this path")
|
||||
p.add_argument("--title", default="jider")
|
||||
p.add_argument("--subtitle", default="alc 5.5% · mostly maggot-free")
|
||||
p.add_argument("--labels-per-sheet", type=int, default=4,
|
||||
help="labels stacked vertically on the A4 page")
|
||||
p.add_argument("--page-margin", type=float, default=10.0,
|
||||
help="blank margin around the page in mm")
|
||||
p.add_argument("--label-gap", type=float, default=4.0, help="space between labels in mm")
|
||||
p.add_argument("--dpi", type=int, default=300)
|
||||
p.add_argument("--label-split", type=float, default=0.42,
|
||||
help="fraction of the width given to the apple panel")
|
||||
p.add_argument("--label-background", default="#ffffff", help="label colour")
|
||||
p.add_argument("--label-ink", default="#7a1f12", help="text and border colour")
|
||||
p.add_argument("--label-font", default=os.path.join(HERE, "assets/fonts/comic.ttf"),
|
||||
help="subtitle font")
|
||||
p.add_argument("--label-title-font", default=os.path.join(HERE, "assets/fonts/comicbd.ttf"),
|
||||
help="title font")
|
||||
return p.parse_known_args()
|
||||
|
||||
|
||||
def render_apple(model, render_args, out_path):
|
||||
# --resolution 1500 is a default the caller's own --resolution overrides (last one wins).
|
||||
cmd = [sys.executable, RENDER_MODEL, model, "--resolution", "1500", *render_args,
|
||||
"--transparent", "--output", out_path]
|
||||
print("Running:", " ".join(cmd))
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode != 0 or not os.path.exists(out_path):
|
||||
sys.exit(f"render_model.py failed (exit code {result.returncode})")
|
||||
|
||||
|
||||
def fit_font(path, text, max_w, max_h):
|
||||
"""Largest font size at which `text` fits inside max_w x max_h."""
|
||||
lo, hi = 4, 2000
|
||||
while lo < hi:
|
||||
size = (lo + hi + 1) // 2
|
||||
left, top, right, bottom = ImageFont.truetype(path, size).getbbox(text)
|
||||
if right - left <= max_w and bottom - top <= max_h:
|
||||
lo = size
|
||||
else:
|
||||
hi = size - 1
|
||||
return ImageFont.truetype(path, lo)
|
||||
|
||||
|
||||
def draw_centered(draw, font, text, cx, top, fill):
|
||||
"""Draw text horizontally centred on cx with its ink starting at y = top."""
|
||||
left, t, right, _ = font.getbbox(text)
|
||||
draw.text((cx - (left + right) / 2, top - t), text, font=font, fill=fill)
|
||||
|
||||
|
||||
def main():
|
||||
args, render_args = parse_args()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
apple_path = args.save_render or os.path.join(tmp, "apple.png")
|
||||
render_apple(args.model, render_args, apple_path)
|
||||
apple = Image.open(apple_path).convert("RGBA")
|
||||
|
||||
# A4 page with the labels stacked vertically.
|
||||
def px(mm):
|
||||
return round(mm / 25.4 * args.dpi)
|
||||
|
||||
page_w, page_h = px(A4_MM[0]), px(A4_MM[1])
|
||||
n = args.labels_per_sheet
|
||||
margin, gap = px(args.page_margin), px(args.label_gap)
|
||||
label_w = page_w - 2 * margin
|
||||
label_h = (page_h - 2 * margin - (n - 1) * gap) // n
|
||||
label = compose_label(args, apple, label_w, label_h)
|
||||
|
||||
page = Image.new("RGB", (page_w, page_h), "white")
|
||||
for i in range(n):
|
||||
page.paste(label, (margin, margin + i * (label_h + gap)))
|
||||
page.save(args.output, dpi=(args.dpi, args.dpi), resolution=args.dpi)
|
||||
print(f"Wrote {args.output}: {n} labels of "
|
||||
f"{label_w / args.dpi * 25.4:.0f} x {label_h / args.dpi * 25.4:.1f} mm on A4")
|
||||
|
||||
|
||||
def compose_label(args, apple, W, H):
|
||||
pad = round(H * 0.07)
|
||||
label = Image.new("RGBA", (W, H), args.label_background)
|
||||
draw = ImageDraw.Draw(label)
|
||||
ink = args.label_ink
|
||||
|
||||
# Double border.
|
||||
for inset, width in [(pad * 0.35, max(2, H // 120)), (pad * 0.55, max(1, H // 300))]:
|
||||
draw.rounded_rectangle((inset, inset, W - inset, H - inset), radius=pad * 0.6,
|
||||
outline=ink, width=round(width))
|
||||
|
||||
# Left panel: apple trimmed to its pixels, scaled to fit, with a soft contact shadow.
|
||||
apple = apple.crop(apple.getchannel("A").getbbox())
|
||||
panel_w = round(W * args.label_split)
|
||||
box_w, box_h = panel_w - 2 * pad, H - 2.6 * pad
|
||||
scale = min(box_w / apple.width, box_h / apple.height)
|
||||
apple = apple.resize((round(apple.width * scale), round(apple.height * scale)), Image.LANCZOS)
|
||||
ax = pad + (box_w - apple.width) // 2
|
||||
ay = round(1.1 * pad + (box_h - apple.height) / 2)
|
||||
|
||||
shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
sw, sh = apple.width * 0.75, apple.height * 0.08
|
||||
scx, scy = ax + apple.width / 2, ay + apple.height - sh * 0.3
|
||||
ImageDraw.Draw(shadow).ellipse((scx - sw / 2, scy - sh / 2, scx + sw / 2, scy + sh / 2),
|
||||
fill=(60, 30, 10, 110))
|
||||
label.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(H / 60)))
|
||||
label.alpha_composite(apple, (ax, ay))
|
||||
|
||||
# Right panel: big title over the subtitle, the pair centred vertically.
|
||||
text_l, text_r = panel_w, W - 1.5 * pad
|
||||
text_w, cx = text_r - text_l, (text_l + text_r) / 2
|
||||
title_font = fit_font(args.label_title_font, args.title, text_w, H * 0.5)
|
||||
sub_font = fit_font(args.label_font, args.subtitle, text_w, H * 0.09)
|
||||
_, tt, _, tb = title_font.getbbox(args.title)
|
||||
_, st, _, sb = sub_font.getbbox(args.subtitle)
|
||||
gap = H * 0.06
|
||||
top = (H - ((tb - tt) + gap + (sb - st))) / 2
|
||||
draw_centered(draw, title_font, args.title, cx, top, ink)
|
||||
draw_centered(draw, sub_font, args.subtitle, cx, top + (tb - tt) + gap, ink)
|
||||
|
||||
return label.convert("RGB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 209 KiB |